Skip to main content

microvms_app/control/
token.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Idempotency tokens, unique per attempt by construction (TRAP-1).
3//!
4//! # What a `clientToken` actually is
5//!
6//! A **permanent** idempotency key, not a request deduplication window. Measured
7//! 2026-08-02, the expensive way: delete an image, recreate it from the same bytes
8//! under the same name with a token derived from that identity, and the service
9//! replays the original create as a no-op. The image then sits in `CREATING` with its
10//! builds never scheduled, cannot be deleted (`CREATING` forbids it), and its only
11//! version cannot be dropped either (it is the last one). Two images were wedged that
12//! way for roughly fifteen hours.
13//!
14//! # Fresh defaults and explicit launch recovery
15//!
16//! The previous Python shape defaulted correctly and *accepted* `client_token=<content
17//! digest>` — which is precisely the value that wedges an image, offered as the
18//! natural thing to pass. Image creation still forbids caller tokens:
19//! [`create_token_with`] and [`run_token_with`] take a **scope label** and mint the token
20//! themselves. A label lands *next to* the nonce and can never replace it.
21//!
22//! Run requests additionally accept a persisted `client_token` for recovery after a lost
23//! launch response. That opt-in must identify exactly one launch with identical inputs.
24//! A new VM needs a new key, even when its image and other parameters are unchanged.
25//!
26//! # The shape, and which end gets truncated
27//!
28//! `<verb>-<label>-<16 hex>`, with `verb` one of `create` or `run` and the nonce eight
29//! bytes of fresh randomness rendered as sixteen hex characters.
30//!
31//! The label is truncated to its **tail**, not its head: `run`'s scope defaults to the
32//! image identifier, which is a full ARN, and an ARN's distinguishing part is the
33//! resource name at the end while every ARN in a region shares its prefix. Truncating
34//! the head would collapse two different images onto one label.
35//!
36//! The nonce is **never** truncated. That ordering is the whole property: a shortened
37//! label cannot make two attempts collide, because collision-freedom lives entirely in
38//! the sixteen hex characters and the truncation happens before them. Found 2026-08-07
39//! by the drift checker's coverage report naming `RunMicrovmRequestClientTokenString`
40//! as unbound — an ap-northeast-1 ARN carrying a legal 64-character image name mints a
41//! 142-character token, over the 128 ceiling, and botocore does not check `max`, so it
42//! would have gone to the wire and failed a launch on a field the caller never set.
43
44use crate::constants::MAX_CLIENT_TOKEN_LEN;
45use crate::entropy::Entropy;
46use crate::error::Error;
47
48/// Bytes of randomness folded into every token: eight, so two attempts one second
49/// apart do not collide even under a retry storm.
50///
51/// Rendered as [`TOKEN_NONCE_HEX_LEN`] hex characters. Both constants exist because
52/// the *hex* length is what the ceiling arithmetic needs and deriving it in three
53/// places is three places to get the factor of two wrong.
54const TOKEN_NONCE_BYTES: usize = 8;
55
56/// The nonce's rendered width, `TOKEN_NONCE_BYTES * 2`.
57///
58/// Test-only, and deliberately so: production code renders the nonce by iterating the bytes,
59/// so a second constant it *used* would be a second thing to keep in step. The tests below
60/// need it to check the ceiling arithmetic — and they compare it against
61/// `TOKEN_NONCE_BYTES * 2` rather than trusting it, so a change to either is caught.
62#[cfg(test)]
63const TOKEN_NONCE_HEX_LEN: usize = TOKEN_NONCE_BYTES * 2;
64
65/// How much of the scope label survives into a token.
66///
67/// Not a cosmetic cap — see the module docs for the 142-character token that motivated
68/// it. Sized so that the worst legal scope still fits [`MAX_CLIENT_TOKEN_LEN`]: the
69/// test at the bottom of this file asserts the arithmetic rather than trusting it.
70const MAX_TOKEN_SCOPE_LEN: usize = 64;
71
72/// The verb prefix, closed over the two operations that carry an idempotency token on
73/// this client's paths.
74///
75/// An enum rather than a `&str` parameter because a third verb is a new operation and
76/// should be a change to this file, not a string a call site invents. `UpdateMicrovmImage`
77/// is the third `idempotencyToken: true` member in the model and is deliberately absent:
78/// this client never calls it.
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80enum Verb {
81    Create,
82    Run,
83}
84
85impl Verb {
86    fn as_str(self) -> &'static str {
87        match self {
88            Verb::Create => "create",
89            Verb::Run => "run",
90        }
91    }
92}
93
94/// An image-create idempotency token, unique per attempt, with its nonce drawn from `entropy`.
95///
96/// `scope` is a **label only** — a readability aid for whoever reads CloudTrail, folded
97/// in beside the nonce. It cannot become the token, and there is no parameter that can:
98/// see the module docs for the fifteen-hour wedge that closure exists to prevent
99/// (`docs/PLATFORM.md`, "`clientToken` is a permanent idempotency key"). An unavailable
100/// source is an error. `microvms_core::control::token::create_token` is this over the OS
101/// pool, under the name it had.
102pub fn create_token_with(scope: &str, entropy: &dyn Entropy) -> Result<String, Error> {
103    Ok(mint(Verb::Create, scope, &nonce_from(entropy)?))
104}
105
106/// A run idempotency token, unique per attempt, with its nonce drawn from `entropy`. Same
107/// rule as [`create_token_with`].
108///
109/// Cheaper to get wrong than the image case — a replayed run returns the original
110/// MicroVM rather than wedging anything — but the failure is worse to read: a caller
111/// who asked for a second VM gets the first one's id back, and two callers then drive
112/// the same guest. `microvms_core::control::token::run_token` is this over the OS pool.
113pub fn run_token_with(scope: &str, entropy: &dyn Entropy) -> Result<String, Error> {
114    Ok(mint(Verb::Run, scope, &nonce_from(entropy)?))
115}
116
117/// Refuse malformed explicit launch keys locally, without echoing caller input.
118pub(crate) fn require_run_token(token: &str) -> Result<(), crate::Error> {
119    if token.is_empty()
120        || token.len() > MAX_CLIENT_TOKEN_LEN
121        || !token.bytes().all(|byte| byte.is_ascii_graphic())
122    {
123        return Err(crate::Error::invalid_arg(format!(
124            "client_token must contain 1..={MAX_CLIENT_TOKEN_LEN} printable ASCII characters without whitespace",
125        )));
126    }
127    Ok(())
128}
129
130/// A caller's log-stream prefix with a fresh per-build discriminator: `<prefix>/<16 hex>`.
131///
132/// # The same mechanism as the token nonce, for the same class of reason
133///
134/// `logging.cloudWatch.logStream` is an **exact** stream name on the wire — prefixes are
135/// unsupported (docs/PLATFORM.md, 'An image build is three VMs and three log streams') —
136/// and one image build emits three log streams. A caller's fixed name sent verbatim
137/// collapses all three of every build, concurrent builds of different images included,
138/// into one stream nothing can tell apart. So the caller's value is a prefix by contract
139/// ([`crate::control::CreateImageRequest::log_stream`]), and this appends eight bytes of
140/// fresh CSPRNG rendered as sixteen hex characters per create attempt — the exact nonce
141/// [`create_token_with`] folds into an idempotency token, reused rather than reinvented.
142///
143/// The arithmetic that keeps the result legal is `MAX_USER_LOG_STREAM_LEN`'s: the caller's
144/// prefix is capped at 495 before this runs, so `495 + 1 + 16` is exactly the shape's 512
145/// ceiling. The debug assertion holds that rather than trusting it.
146pub(crate) fn resolve_log_stream(prefix: &str, entropy: &dyn Entropy) -> Result<String, Error> {
147    let nonce = nonce_from(entropy)?;
148    let mut resolved = String::with_capacity(prefix.len() + 1 + nonce.len() * 2);
149    resolved.push_str(prefix);
150    resolved.push('/');
151    resolved.push_str(&const_hex::encode(nonce));
152    debug_assert!(
153        resolved.len() <= crate::constants::MAX_LOG_STREAM_LEN,
154        "resolved a {}-character stream name, over the {} ceiling: {resolved}",
155        resolved.len(),
156        crate::constants::MAX_LOG_STREAM_LEN,
157    );
158    Ok(resolved)
159}
160
161/// The one place a token is assembled, with the randomness passed in.
162///
163/// Split from the two public functions so a test can drive the *format* with a fixed
164/// nonce and still exercise the real assembly, rather than reimplementing the format in
165/// the test and asserting a copy of it against itself.
166fn mint(verb: Verb, scope: &str, nonce: &[u8]) -> String {
167    // The tail, and on a character boundary: `scope` is arbitrary caller text, so
168    // slicing by byte index would panic on a multi-byte character straddling the cut.
169    // `char_indices` finds the first boundary at or after the cut point, which keeps at
170    // most MAX_TOKEN_SCOPE_LEN bytes — never more, which is what the ceiling needs.
171    let label = tail(scope, MAX_TOKEN_SCOPE_LEN);
172
173    let mut token = String::with_capacity(verb.as_str().len() + 2 + label.len() + nonce.len() * 2);
174    token.push_str(verb.as_str());
175    token.push('-');
176    token.push_str(label);
177    token.push('-');
178    // Two lowercase hex digits per byte, so the rendered width is exactly
179    // 2 * nonce.len() and the ceiling arithmetic below holds.
180    token.push_str(&const_hex::encode(nonce));
181
182    debug_assert!(
183        token.len() <= MAX_CLIENT_TOKEN_LEN,
184        "minted a {}-character token, over the {MAX_CLIENT_TOKEN_LEN} ceiling: {token}",
185        token.len()
186    );
187    token
188}
189
190/// The last `max_bytes` bytes of `text`, cut at a character boundary.
191///
192/// Returns a possibly-shorter slice when the boundary falls inside a multi-byte
193/// character, which is the safe direction: the ceiling is an upper bound.
194fn tail(text: &str, max_bytes: usize) -> &str {
195    if text.len() <= max_bytes {
196        return text;
197    }
198    let cut = text.len() - max_bytes;
199    match text.char_indices().find(|(index, _)| *index >= cut) {
200        Some((index, _)) => &text[index..],
201        // Unreachable in practice — the loop above finds the final boundary at worst —
202        // but an empty label is a correct token and a panic is not.
203        None => "",
204    }
205}
206
207/// Eight bytes of fresh randomness per call, from `entropy`.
208///
209/// TRAP-1 needs per-attempt distinctness — the token must not repeat across two
210/// attempts; it does not have to be unguessable. The OS pool gives the stronger
211/// property anyway, and fails only when it is genuinely unavailable, in which state a
212/// clock-derived nonce (the old fallback here) risks the exact collision TRAP-1 exists
213/// to prevent. So an unavailable source is an error, never a fallback.
214fn nonce_from(entropy: &dyn Entropy) -> Result<[u8; TOKEN_NONCE_BYTES], Error> {
215    let mut bytes = [0u8; TOKEN_NONCE_BYTES];
216    entropy.fill(&mut bytes)?;
217    Ok(bytes)
218}
219
220#[cfg(test)]
221mod tests {
222    use std::collections::HashSet;
223
224    use std::sync::LazyLock;
225
226    use proptest::prelude::*;
227
228    use super::*;
229    use crate::entropy::testing::SequenceEntropy;
230
231    /// One source for the whole module, so every token a test here mints is a fresh draw. The
232    /// OS pool's own distinctness is `microvms-edges`'s `the_os_source_draws_distinct_bytes`.
233    static SOURCE: LazyLock<SequenceEntropy> = LazyLock::new(SequenceEntropy::new);
234
235    fn create_token(scope: &str) -> String {
236        create_token_with(scope, &*SOURCE).expect("scripted")
237    }
238
239    fn run_token(scope: &str) -> String {
240        run_token_with(scope, &*SOURCE).expect("scripted")
241    }
242
243    /// The worst legal scope, from the discovery that motivated the cap: a full
244    /// ap-northeast-1 image ARN carrying a 64-character image name. This is the value
245    /// that minted 142 characters before the label was truncated.
246    fn worst_legal_scope() -> String {
247        format!(
248            "arn:aws:lambda:ap-northeast-1:123456789012:microvm-image:{}",
249            "a".repeat(crate::constants::MAX_IMAGE_NAME_LEN)
250        )
251    }
252
253    /// The shape, against a fixed nonce so the assertion is on the format rather than
254    /// on a copy of the format. `create-`, the label, `-`, sixteen hex characters.
255    #[test]
256    fn a_token_is_the_verb_the_label_and_sixteen_hex_characters() {
257        let token = mint(
258            Verb::Create,
259            "agentd-conformance",
260            &[0xde, 0xad, 0xbe, 0xef, 0, 1, 2, 3],
261        );
262        assert_eq!(token, "create-agentd-conformance-deadbeef00010203");
263
264        let token = mint(Verb::Run, "vm-7", &[0xff; TOKEN_NONCE_BYTES]);
265        assert_eq!(token, "run-vm-7-ffffffffffffffff");
266    }
267
268    /// The two verbs are the two this client uses, and they are distinguishable in the
269    /// minted value — which is the only reason the verb is in there at all: a CloudTrail
270    /// reader seeing `create-…` knows which call it came from.
271    #[test]
272    fn the_verb_prefix_distinguishes_a_create_from_a_run() {
273        assert!(create_token("img").starts_with("create-"));
274        assert!(run_token("img").starts_with("run-"));
275    }
276
277    /// TRAP-1's headline property, at the scale the success criteria name: 200 draws,
278    /// 200 distinct values.
279    ///
280    /// **Falsification** — replace `nonce_from()` in `create_token_with` with a digest of
281    /// the scope (the exact mistake the Python client's `client_token=` parameter
282    /// invited) and this collapses to one distinct value out of 200. Verified by doing
283    /// it; see the packet's guard proofs.
284    #[test]
285    fn two_hundred_create_tokens_for_one_scope_are_two_hundred_distinct_values() {
286        let scope = "agentd-conformance";
287        let minted: HashSet<String> = (0..200).map(|_| create_token(scope)).collect();
288        assert_eq!(minted.len(), 200, "a repeated token is a wedged image");
289    }
290
291    /// The same for the run path, and across the two verbs at once: 200 of each, 400
292    /// distinct, so a create token can never equal a run token either.
293    #[test]
294    fn create_and_run_tokens_never_collide_with_each_other() {
295        let scope = "arn:aws:lambda:us-east-1:123456789012:microvm-image:img";
296        let mut minted = HashSet::new();
297        for _ in 0..200 {
298            minted.insert(create_token(scope));
299            minted.insert(run_token(scope));
300        }
301        assert_eq!(minted.len(), 400);
302    }
303
304    /// The ceiling, at the value that discovered it. 142 characters before truncation,
305    /// and the assertion is on the real minted length rather than on the arithmetic.
306    #[test]
307    fn the_worst_legal_scope_fits_the_hundred_twenty_eight_character_ceiling() {
308        let scope = worst_legal_scope();
309        assert!(
310            scope.len() > MAX_TOKEN_SCOPE_LEN,
311            "the point of this scope is that it is longer than the label cap"
312        );
313
314        for token in [create_token(&scope), run_token(&scope)] {
315            assert!(
316                token.len() <= MAX_CLIENT_TOKEN_LEN,
317                "{} characters, over the {MAX_CLIENT_TOKEN_LEN} ceiling: {token}",
318                token.len()
319            );
320        }
321    }
322
323    /// The arithmetic behind the cap, stated so a change to any of the three numbers
324    /// fails here rather than on a launch. `run-` is the shorter verb, so `create-`
325    /// bounds the total.
326    #[test]
327    fn the_label_cap_leaves_room_for_the_longest_verb_and_the_whole_nonce() {
328        let longest_verb = Verb::Create.as_str().len();
329        let worst = longest_verb + 1 + MAX_TOKEN_SCOPE_LEN + 1 + TOKEN_NONCE_HEX_LEN;
330        assert!(
331            worst <= MAX_CLIENT_TOKEN_LEN,
332            "the worst legal token is {worst} characters against a ceiling of {MAX_CLIENT_TOKEN_LEN}"
333        );
334        assert_eq!(TOKEN_NONCE_HEX_LEN, TOKEN_NONCE_BYTES * 2);
335    }
336
337    /// The truncation keeps the **tail**. Two ARNs differing only in their resource
338    /// name must produce different labels — truncating the head would collapse them,
339    /// because every ARN in a region shares its prefix.
340    ///
341    /// The image names are long enough that the shared prefix really is what gets cut:
342    /// the ARN prefix alone is 57 characters, so a short name leaves the whole scope
343    /// under the 64-byte cap and the test would pass without exercising truncation at
344    /// all. That near-miss is the reason the length is asserted first.
345    #[test]
346    fn truncation_keeps_the_tail_so_two_arns_stay_distinguishable() {
347        let prefix = "arn:aws:lambda:ap-northeast-1:123456789012:microvm-image:";
348        let first_scope = format!("{prefix}alpha-conformance-image-name");
349        let second_scope = format!("{prefix}omega-conformance-image-name");
350        assert!(
351            first_scope.len() > MAX_TOKEN_SCOPE_LEN,
352            "these scopes must actually be truncated or the test proves nothing"
353        );
354
355        let first = mint(Verb::Run, &first_scope, &[0; TOKEN_NONCE_BYTES]);
356        let second = mint(Verb::Run, &second_scope, &[0; TOKEN_NONCE_BYTES]);
357
358        assert_ne!(first, second, "the distinguishing part is at the end");
359        assert!(first.contains("alpha-conformance-image-name"), "{first}");
360        assert!(second.contains("omega-conformance-image-name"), "{second}");
361        assert!(
362            !first.contains("arn:aws"),
363            "the shared prefix is what got cut: {first}"
364        );
365    }
366
367    /// The nonce survives truncation intact, whatever the label does. This is the
368    /// property that makes collision-freedom independent of the scope: the last sixteen
369    /// characters are always the sixteen hex digits.
370    #[test]
371    fn the_nonce_is_never_the_part_that_gets_cut() {
372        let scope = "z".repeat(4096);
373        let token = mint(Verb::Create, &scope, &[0xab; TOKEN_NONCE_BYTES]);
374        assert!(
375            token.ends_with("abababababababab"),
376            "the whole nonce must survive: {token}"
377        );
378        assert_eq!(
379            token.len(),
380            "create".len() + 1 + MAX_TOKEN_SCOPE_LEN + 1 + TOKEN_NONCE_HEX_LEN
381        );
382    }
383
384    /// A multi-byte scope does not panic and does not overshoot the ceiling. The cut
385    /// point can land inside a character, and slicing by byte index there is a panic in
386    /// a code path a caller reaches by naming an image in their own language.
387    #[test]
388    fn a_multibyte_scope_cuts_on_a_character_boundary_rather_than_panicking() {
389        for scope in ["日本語".repeat(64), "é".repeat(200), "🔥".repeat(50)] {
390            let token = create_token(&scope);
391            assert!(token.len() <= MAX_CLIENT_TOKEN_LEN, "{token}");
392            assert!(token.is_char_boundary(token.len()));
393        }
394    }
395
396    /// An empty scope is a legal token rather than a panic or an empty string: the
397    /// model's minimum is 1, and `create--<nonce>` clears it comfortably.
398    #[test]
399    fn an_empty_scope_still_mints_a_usable_token() {
400        let token = create_token("");
401        assert_eq!(token, format!("create--{}", &token[8..]));
402        // The clientToken shapes state `min: 1`, and nothing outside this crate would catch a
403        // violation of it — the note here used to say botocore enforces `min` locally, which is
404        // true of botocore and irrelevant to a client that signs with `aws-sigv4` and sends with
405        // `reqwest` (issue #24). What makes the constraint unreachable is not a guard but the
406        // minter: the verb and the nonce alone are 23 characters, so the empty label cannot
407        // collapse the token, and that is what the assertion below says.
408        assert_eq!(
409            token.len(),
410            "create".len() + 2 + TOKEN_NONCE_HEX_LEN,
411            "the model's clientToken min is 1 and this clears it: {token}"
412        );
413    }
414
415    proptest! {
416        /// The ceiling and the nonce, over arbitrary caller text. A hand-picked scope
417        /// set is exactly where the multi-byte boundary case hides, and the property
418        /// that matters — every minted token fits, and every one ends in its full nonce
419        /// — has to hold for text this test did not choose.
420        #[test]
421        fn every_scope_mints_a_token_that_fits_and_keeps_its_whole_nonce(scope: String) {
422            let token = mint(Verb::Create, &scope, &[0x5a; TOKEN_NONCE_BYTES]);
423            prop_assert!(token.len() <= MAX_CLIENT_TOKEN_LEN, "{}", token);
424            prop_assert!(token.ends_with("5a5a5a5a5a5a5a5a"), "{}", token);
425            prop_assert!(token.starts_with("create-"), "{}", token);
426        }
427
428        /// Two calls with the *same* scope differ, for any scope. The distinctness test
429        /// above uses one fixed scope; this one asserts the scope cannot be the thing
430        /// that makes two attempts agree — which is precisely what a digest-derived
431        /// token would do.
432        #[test]
433        fn no_scope_makes_two_attempts_produce_the_same_token(scope: String) {
434            prop_assert_ne!(create_token(&scope), create_token(&scope));
435            prop_assert_ne!(run_token(&scope), run_token(&scope));
436        }
437    }
438
439    /// A resolved log stream is the caller's prefix, a `/`, and sixteen hex characters —
440    /// never the prefix verbatim — and two resolutions of one prefix differ.
441    ///
442    /// The distinctness is the whole point: one image build is three VMs writing three
443    /// streams under an exact-name member, so a resolution that could repeat would
444    /// collapse two builds' streams the way a verbatim name collapses every build's.
445    ///
446    /// **Falsification** — return `prefix.to_string()` from `resolve_log_stream` and the
447    /// format assertions and the distinctness assertion all go red.
448    #[test]
449    fn a_resolved_log_stream_carries_the_prefix_a_slash_and_sixteen_fresh_hex() {
450        let first = resolve_log_stream("ci-image", &*SOURCE).expect("the pool is available");
451        let second = resolve_log_stream("ci-image", &*SOURCE).expect("the pool is available");
452
453        for resolved in [&first, &second] {
454            assert!(resolved.starts_with("ci-image/"), "{resolved}");
455            let suffix = &resolved["ci-image/".len()..];
456            assert_eq!(suffix.len(), TOKEN_NONCE_HEX_LEN, "{resolved}");
457            assert!(
458                suffix.bytes().all(|b| b.is_ascii_hexdigit()),
459                "the discriminator is hex: {resolved}"
460            );
461            assert_ne!(resolved.as_str(), "ci-image", "never the prefix verbatim");
462        }
463        assert_ne!(
464            first, second,
465            "two creates from one request must resolve to two streams, or concurrent \
466             builds collapse into one"
467        );
468    }
469
470    /// The worst legal prefix — `MAX_USER_LOG_STREAM_LEN` characters — resolves to exactly
471    /// the shape's 512 ceiling, which is the arithmetic the 495 cap exists to hold.
472    #[test]
473    fn the_longest_legal_prefix_resolves_to_exactly_the_shapes_ceiling() {
474        let prefix = "s".repeat(crate::constants::MAX_USER_LOG_STREAM_LEN);
475        let resolved = resolve_log_stream(&prefix, &*SOURCE).expect("the pool is available");
476        assert_eq!(resolved.len(), crate::constants::MAX_LOG_STREAM_LEN);
477    }
478
479    /// The randomness source produces distinct draws. A `nonce_from` that returned a
480    /// constant would pass a format test and fail this one, and it is the single point
481    /// every distinctness property above rests on.
482    #[test]
483    fn the_nonce_source_does_not_repeat_itself() {
484        let drawn: HashSet<[u8; TOKEN_NONCE_BYTES]> = (0..200)
485            .map(|_| nonce_from(&*SOURCE).expect("the pool is available"))
486            .collect();
487        assert_eq!(drawn.len(), 200);
488        assert!(
489            !drawn.contains(&[0u8; TOKEN_NONCE_BYTES]),
490            "an all-zero draw is the signature of a read that silently did nothing"
491        );
492    }
493
494    /// Every nonce is the source's draw, verbatim, so a control plane's source decides it.
495    ///
496    /// **Falsification**: draw from `OsEntropy` inside `nonce_from` and the three values
497    /// below stop being the scripted draws.
498    #[test]
499    fn the_nonce_is_the_source_s_next_draw() {
500        let entropy = SequenceEntropy::new();
501        let hex = |n| const_hex::encode(SequenceEntropy::draw(n, TOKEN_NONCE_BYTES));
502        assert_eq!(
503            create_token_with("img", &entropy).expect("scripted"),
504            format!("create-img-{}", hex(1))
505        );
506        assert_eq!(
507            run_token_with("img", &entropy).expect("scripted"),
508            format!("run-img-{}", hex(2))
509        );
510        assert_eq!(
511            resolve_log_stream("ci", &entropy).expect("scripted"),
512            format!("ci/{}", hex(3))
513        );
514    }
515
516    /// An unavailable source refuses the token rather than minting one from anything else.
517    #[test]
518    fn an_unavailable_source_refuses_every_token() {
519        let entropy = SequenceEntropy::unavailable();
520        assert!(create_token_with("img", &entropy).is_err());
521        assert!(run_token_with("img", &entropy).is_err());
522        assert!(resolve_log_stream("ci", &entropy).is_err());
523    }
524}