Skip to main content

lightshuttle_spec/
spec.rs

1//! Self-contained container specification, derived from a manifest
2//! resource declaration.
3
4use std::collections::HashMap;
5use std::time::Duration;
6
7use indexmap::IndexMap;
8use lightshuttle_manifest::{
9    Command, ContainerConfig, DockerfileConfig, Healthcheck, PortMapping, PostgresConfig,
10    RedisConfig, ResourceKind, Volume,
11};
12
13use crate::error::{Result, SpecError};
14
15/// Properties a managed resource exposes to its dependents.
16///
17/// Keys follow the conventions documented in
18/// `docs/spec/manifest-v0.md`:
19///
20/// - `host`, `port`, `database`, `user`, `password`, `url` for
21///   `postgres`.
22/// - `host`, `port`, `password`, `url` for `redis`.
23/// - `host`, `ports` (comma-separated) for `container` and
24///   `dockerfile`.
25pub type ResourceOutputs = IndexMap<String, String>;
26
27/// A [`ContainerSpec`] together with the outputs the resource exposes
28/// to its dependents at runtime.
29#[derive(Debug, Clone)]
30pub struct ResolvedResource {
31    /// Container specification consumed by the runtime.
32    pub spec: ContainerSpec,
33    /// Properties exposed to dependents (resolved into LSH_* env vars
34    /// and substituted into `${resources.<name>.<property>}`
35    /// expressions).
36    pub outputs: ResourceOutputs,
37}
38
39const DEFAULT_PG_VERSION: &str = "16";
40const DEFAULT_PG_USER: &str = "postgres";
41const DEFAULT_PG_PORT: u16 = 5432;
42const DEFAULT_REDIS_VERSION: &str = "7";
43const DEFAULT_REDIS_PORT: u16 = 6379;
44const HEALTHCHECK_DEFAULT_INTERVAL: Duration = Duration::from_secs(5);
45const HEALTHCHECK_DEFAULT_TIMEOUT: Duration = Duration::from_secs(3);
46const HEALTHCHECK_DEFAULT_RETRIES: u32 = 5;
47const HEALTHCHECK_DEFAULT_START_PERIOD: Duration = Duration::from_secs(5);
48
49/// Self-contained description of a container to start, derived from a
50/// manifest resource.
51#[derive(Debug, Clone)]
52pub struct ContainerSpec {
53    /// Container name, of the form `<project>_<resource>`.
54    pub name: String,
55    /// Project name as declared in the manifest. Used as a Docker
56    /// label for discovery by `ps` and `down`.
57    pub project: String,
58    /// Resource name as declared in the manifest. Used as a Docker
59    /// label so the CLI can find a single resource by name.
60    pub resource: String,
61    /// How the container image is obtained.
62    pub image: ImageSource,
63    /// Environment variables to inject into the container.
64    pub env: HashMap<String, String>,
65    /// Ports to publish.
66    pub ports: Vec<PortBinding>,
67    /// Volumes to mount.
68    pub volumes: Vec<VolumeBinding>,
69    /// Optional command override.
70    pub command: Option<Vec<String>>,
71    /// Optional healthcheck.
72    pub healthcheck: Option<HealthcheckSpec>,
73    /// Optional working directory override inside the container.
74    pub working_dir: Option<String>,
75}
76
77/// How the container image is obtained.
78#[derive(Debug, Clone)]
79pub enum ImageSource {
80    /// Pull the image from a registry.
81    Pull(String),
82    /// Build the image locally from a Dockerfile.
83    Build {
84        /// Build context path, relative to the manifest file.
85        context: String,
86        /// Dockerfile path within the context.
87        dockerfile: String,
88        /// Build-time arguments.
89        build_args: HashMap<String, String>,
90        /// Optional multi-stage target.
91        target: Option<String>,
92        /// Tag applied to the resulting image.
93        tag: String,
94    },
95}
96
97/// Port mapping resolved from the manifest.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct PortBinding {
100    /// Container-side port.
101    pub container_port: u16,
102    /// Optional host bind address.
103    pub host_address: Option<String>,
104    /// Host-side port. Mirrors the container port when the short form is used.
105    pub host_port: u16,
106}
107
108/// Volume mapping resolved from the manifest.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct VolumeBinding {
111    /// Source on the host or the named volume registry.
112    pub source: VolumeSource,
113    /// Mount point inside the container.
114    pub target: String,
115}
116
117/// Where the volume content lives.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum VolumeSource {
120    /// Bind mount from a host path.
121    HostPath(String),
122    /// Named volume managed by the runtime.
123    Named(String),
124    /// Anonymous volume (lifetime tied to the container).
125    Anonymous,
126}
127
128/// Healthcheck resolved from the manifest, with manifest-side durations
129/// already parsed.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct HealthcheckSpec {
132    /// Command to run for the check.
133    pub test: Vec<String>,
134    /// Interval between consecutive checks.
135    pub interval: Duration,
136    /// Maximum time a single check is allowed.
137    pub timeout: Duration,
138    /// Number of consecutive failures before marking unhealthy.
139    pub retries: u32,
140    /// Grace period after start.
141    pub start_period: Duration,
142}
143
144/// Build a [`ContainerSpec`] from a manifest resource declaration.
145///
146/// Applies the v0 defaults documented in `docs/spec/manifest-v0.md`:
147/// version expansion to official images, database name derived from
148/// the resource name, default ports, healthcheck materialisation.
149pub fn from_resource(
150    project: &str,
151    resource_name: &str,
152    kind: &ResourceKind,
153) -> Result<ResolvedResource> {
154    let name = format!("{project}_{resource_name}");
155    match kind {
156        ResourceKind::Postgres(c) => spec_postgres(name, project, resource_name, c),
157        ResourceKind::Redis(c) => spec_redis(name, project, resource_name, c),
158        ResourceKind::Container(c) => spec_container(name, project, resource_name, c),
159        ResourceKind::Dockerfile(c) => spec_dockerfile(name, project, resource_name, c),
160    }
161}
162
163#[allow(clippy::needless_pass_by_value)]
164fn spec_postgres(
165    name: String,
166    project: &str,
167    resource_name: &str,
168    c: &PostgresConfig,
169) -> Result<ResolvedResource> {
170    let version = c.version.as_deref().unwrap_or(DEFAULT_PG_VERSION);
171    let image = c
172        .image
173        .clone()
174        .unwrap_or_else(|| format!("postgres:{version}-alpine"));
175    let database = c
176        .database
177        .clone()
178        .unwrap_or_else(|| resource_name.to_owned());
179    let user = c.user.clone().unwrap_or_else(|| DEFAULT_PG_USER.to_owned());
180    let password = c.password.clone().unwrap_or_else(generate_random_password);
181    let port = c.port.unwrap_or(DEFAULT_PG_PORT);
182
183    let mut env = HashMap::new();
184    env.insert("POSTGRES_DB".to_owned(), database);
185    env.insert("POSTGRES_USER".to_owned(), user.clone());
186    env.insert("POSTGRES_PASSWORD".to_owned(), password);
187
188    let ports = vec![PortBinding {
189        container_port: port,
190        host_address: None,
191        host_port: port,
192    }];
193
194    let volumes = volume_to_binding(c.volume.as_ref(), "/var/lib/postgresql/data");
195
196    let healthcheck = c
197        .healthcheck
198        .as_ref()
199        .map(parse_healthcheck)
200        .transpose()?
201        .or_else(|| {
202            Some(HealthcheckSpec {
203                test: vec![
204                    "CMD".to_owned(),
205                    "pg_isready".to_owned(),
206                    "-U".to_owned(),
207                    user,
208                ],
209                interval: HEALTHCHECK_DEFAULT_INTERVAL,
210                timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
211                retries: HEALTHCHECK_DEFAULT_RETRIES,
212                start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
213            })
214        });
215
216    let spec = ContainerSpec {
217        name: name.clone(),
218        project: project.to_owned(),
219        resource: resource_name.to_owned(),
220        image: ImageSource::Pull(image),
221        env: env.clone(),
222        ports,
223        volumes,
224        command: None,
225        healthcheck,
226        working_dir: None,
227    };
228
229    let mut outputs = ResourceOutputs::new();
230    outputs.insert("host".to_owned(), name.clone());
231    outputs.insert("port".to_owned(), port.to_string());
232    let user_out = env.get("POSTGRES_USER").cloned().unwrap_or_default();
233    let pwd_out = env.get("POSTGRES_PASSWORD").cloned().unwrap_or_default();
234    let db_out = env.get("POSTGRES_DB").cloned().unwrap_or_default();
235    outputs.insert("user".to_owned(), user_out.clone());
236    outputs.insert("password".to_owned(), pwd_out.clone());
237    outputs.insert("database".to_owned(), db_out.clone());
238    outputs.insert(
239        "url".to_owned(),
240        format!("postgres://{user_out}:{pwd_out}@{name}:{port}/{db_out}"),
241    );
242
243    Ok(ResolvedResource { spec, outputs })
244}
245
246#[allow(clippy::needless_pass_by_value)]
247fn spec_redis(
248    name: String,
249    project: &str,
250    resource_name: &str,
251    c: &RedisConfig,
252) -> Result<ResolvedResource> {
253    let version = c.version.as_deref().unwrap_or(DEFAULT_REDIS_VERSION);
254    let image = c
255        .image
256        .clone()
257        .unwrap_or_else(|| format!("redis:{version}-alpine"));
258    let port = c.port.unwrap_or(DEFAULT_REDIS_PORT);
259
260    let mut command = vec!["redis-server".to_owned()];
261    if let Some(password) = c.password.as_deref()
262        && !password.is_empty()
263    {
264        command.push("--requirepass".to_owned());
265        command.push(password.to_owned());
266    }
267
268    let ports = vec![PortBinding {
269        container_port: port,
270        host_address: None,
271        host_port: port,
272    }];
273
274    let volumes = volume_to_binding(c.volume.as_ref(), "/data");
275
276    let healthcheck = c
277        .healthcheck
278        .as_ref()
279        .map(parse_healthcheck)
280        .transpose()?
281        .or_else(|| {
282            Some(HealthcheckSpec {
283                test: vec!["CMD".to_owned(), "redis-cli".to_owned(), "ping".to_owned()],
284                interval: HEALTHCHECK_DEFAULT_INTERVAL,
285                timeout: HEALTHCHECK_DEFAULT_TIMEOUT,
286                retries: HEALTHCHECK_DEFAULT_RETRIES,
287                start_period: HEALTHCHECK_DEFAULT_START_PERIOD,
288            })
289        });
290
291    let password_out = c.password.clone().unwrap_or_default();
292    let spec = ContainerSpec {
293        name: name.clone(),
294        project: project.to_owned(),
295        resource: resource_name.to_owned(),
296        image: ImageSource::Pull(image),
297        env: HashMap::new(),
298        ports,
299        volumes,
300        command: Some(command),
301        healthcheck,
302        working_dir: None,
303    };
304
305    let mut outputs = ResourceOutputs::new();
306    outputs.insert("host".to_owned(), name.clone());
307    outputs.insert("port".to_owned(), port.to_string());
308    outputs.insert("password".to_owned(), password_out.clone());
309    let url = if password_out.is_empty() {
310        format!("redis://{name}:{port}")
311    } else {
312        format!("redis://:{password_out}@{name}:{port}")
313    };
314    outputs.insert("url".to_owned(), url);
315
316    Ok(ResolvedResource { spec, outputs })
317}
318
319#[allow(clippy::needless_pass_by_value)]
320fn spec_container(
321    name: String,
322    project: &str,
323    resource_name: &str,
324    c: &ContainerConfig,
325) -> Result<ResolvedResource> {
326    let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
327
328    let ports = c
329        .ports
330        .iter()
331        .map(parse_port_mapping)
332        .collect::<Result<Vec<_>>>()?;
333    let volumes = c
334        .volumes
335        .iter()
336        .map(|s| parse_volume_string(s))
337        .collect::<Result<Vec<_>>>()?;
338    let command = c
339        .command
340        .as_ref()
341        .map(parse_command)
342        .filter(|cmd| !cmd.is_empty());
343    let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
344
345    let ports_csv: String = ports
346        .iter()
347        .map(|p| p.container_port.to_string())
348        .collect::<Vec<_>>()
349        .join(",");
350    let spec = ContainerSpec {
351        name: name.clone(),
352        project: project.to_owned(),
353        resource: resource_name.to_owned(),
354        image: ImageSource::Pull(c.image.clone()),
355        env,
356        ports,
357        volumes,
358        command,
359        healthcheck,
360        working_dir: c.working_dir.clone(),
361    };
362
363    let mut outputs = ResourceOutputs::new();
364    outputs.insert("host".to_owned(), name);
365    outputs.insert("ports".to_owned(), ports_csv);
366
367    Ok(ResolvedResource { spec, outputs })
368}
369
370#[allow(clippy::needless_pass_by_value)]
371fn spec_dockerfile(
372    name: String,
373    project: &str,
374    resource_name: &str,
375    c: &DockerfileConfig,
376) -> Result<ResolvedResource> {
377    let tag = format!("lightshuttle/{name}:dev");
378
379    let env: HashMap<String, String> = c.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
380
381    let build_args: HashMap<String, String> = c
382        .build_args
383        .iter()
384        .map(|(k, v)| (k.clone(), v.clone()))
385        .collect();
386
387    let ports = c
388        .ports
389        .iter()
390        .map(parse_port_mapping)
391        .collect::<Result<Vec<_>>>()?;
392    let volumes = c
393        .volumes
394        .iter()
395        .map(|s| parse_volume_string(s))
396        .collect::<Result<Vec<_>>>()?;
397    let command = c
398        .command
399        .as_ref()
400        .map(parse_command)
401        .filter(|cmd| !cmd.is_empty());
402    let healthcheck = c.healthcheck.as_ref().map(parse_healthcheck).transpose()?;
403
404    let ports_csv: String = ports
405        .iter()
406        .map(|p| p.container_port.to_string())
407        .collect::<Vec<_>>()
408        .join(",");
409    let spec = ContainerSpec {
410        name: name.clone(),
411        project: project.to_owned(),
412        resource: resource_name.to_owned(),
413        image: ImageSource::Build {
414            context: c.context.clone(),
415            dockerfile: c.dockerfile.clone(),
416            build_args,
417            target: c.target.clone(),
418            tag,
419        },
420        env,
421        ports,
422        volumes,
423        command,
424        healthcheck,
425        working_dir: c.working_dir.clone(),
426    };
427
428    let mut outputs = ResourceOutputs::new();
429    outputs.insert("host".to_owned(), name);
430    outputs.insert("ports".to_owned(), ports_csv);
431
432    Ok(ResolvedResource { spec, outputs })
433}
434
435fn volume_to_binding(volume: Option<&Volume>, target: &str) -> Vec<VolumeBinding> {
436    match volume {
437        None | Some(Volume::Boolean(true)) => vec![VolumeBinding {
438            source: VolumeSource::Anonymous,
439            target: target.to_owned(),
440        }],
441        Some(Volume::Boolean(false)) => Vec::new(),
442        Some(Volume::Named(name)) => vec![VolumeBinding {
443            source: VolumeSource::Named(name.clone()),
444            target: target.to_owned(),
445        }],
446    }
447}
448
449fn parse_port_mapping(mapping: &PortMapping) -> Result<PortBinding> {
450    match mapping {
451        PortMapping::Container(port) => Ok(PortBinding {
452            container_port: *port,
453            host_address: None,
454            host_port: *port,
455        }),
456        PortMapping::Mapping(s) => parse_port_string(s),
457    }
458}
459
460fn parse_port_string(input: &str) -> Result<PortBinding> {
461    let parts: Vec<&str> = input.split(':').collect();
462    match parts.as_slice() {
463        [host_port, container_port] => {
464            let host_port: u16 = host_port
465                .parse()
466                .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
467            let container_port: u16 = container_port.parse().map_err(|_| {
468                SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
469            })?;
470            Ok(PortBinding {
471                container_port,
472                host_address: None,
473                host_port,
474            })
475        }
476        [host_address, host_port, container_port] => {
477            let host_port: u16 = host_port
478                .parse()
479                .map_err(|_| SpecError::InvalidSpec(format!("invalid host port `{host_port}`")))?;
480            let container_port: u16 = container_port.parse().map_err(|_| {
481                SpecError::InvalidSpec(format!("invalid container port `{container_port}`"))
482            })?;
483            Ok(PortBinding {
484                container_port,
485                host_address: Some((*host_address).to_owned()),
486                host_port,
487            })
488        }
489        _ => Err(SpecError::InvalidSpec(format!(
490            "invalid port mapping `{input}`"
491        ))),
492    }
493}
494
495fn parse_volume_string(input: &str) -> Result<VolumeBinding> {
496    let (source, target) = input.split_once(':').ok_or_else(|| {
497        SpecError::InvalidSpec(format!(
498            "invalid volume mapping `{input}`: expected `src:target`"
499        ))
500    })?;
501    let source = if source.starts_with('.') || source.starts_with('/') {
502        VolumeSource::HostPath(source.to_owned())
503    } else {
504        if source.contains(['{', '}']) {
505            return Err(SpecError::InvalidSpec(format!(
506                "volume name `{source}` must not contain '{{' or '}}': unsafe in export templates"
507            )));
508        }
509        VolumeSource::Named(source.to_owned())
510    };
511    Ok(VolumeBinding {
512        source,
513        target: target.to_owned(),
514    })
515}
516
517fn parse_command(command: &Command) -> Vec<String> {
518    match command {
519        Command::Single(s) => vec!["sh".to_owned(), "-c".to_owned(), s.clone()],
520        Command::Args(args) => args.clone(),
521    }
522}
523
524fn parse_healthcheck(hc: &Healthcheck) -> Result<HealthcheckSpec> {
525    Ok(HealthcheckSpec {
526        test: hc.test.clone(),
527        interval: parse_duration(&hc.interval)?,
528        timeout: parse_duration(&hc.timeout)?,
529        retries: hc.retries,
530        start_period: parse_duration(&hc.start_period)?,
531    })
532}
533
534fn parse_duration(input: &str) -> Result<Duration> {
535    let trimmed = input.trim();
536    let (digits, unit) = split_duration(trimmed)
537        .ok_or_else(|| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
538    let value: f64 = digits
539        .parse()
540        .map_err(|_| SpecError::InvalidSpec(format!("invalid duration `{input}`")))?;
541    let nanos = match unit {
542        "ns" => value,
543        "us" => value * 1_000.0,
544        "ms" => value * 1_000_000.0,
545        "s" => value * 1_000_000_000.0,
546        "m" => value * 60.0 * 1_000_000_000.0,
547        "h" => value * 3_600.0 * 1_000_000_000.0,
548        _ => {
549            return Err(SpecError::InvalidSpec(format!(
550                "invalid duration unit `{unit}`"
551            )));
552        }
553    };
554    if nanos.is_sign_negative() || !nanos.is_finite() {
555        return Err(SpecError::InvalidSpec(format!(
556            "invalid duration `{input}`"
557        )));
558    }
559    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
560    Ok(Duration::from_nanos(nanos as u64))
561}
562
563fn split_duration(input: &str) -> Option<(&str, &str)> {
564    let bytes = input.as_bytes();
565    let mut idx = 0;
566    while idx < bytes.len() && (bytes[idx].is_ascii_digit() || bytes[idx] == b'.') {
567        idx += 1;
568    }
569    if idx == 0 || idx == bytes.len() {
570        return None;
571    }
572    Some((&input[..idx], &input[idx..]))
573}
574
575/// Generate a 24-character alphanumeric password from a cryptographically
576/// secure random source.
577///
578/// The alphabet excludes visually ambiguous characters (`0`, `O`, `1`,
579/// `I`, `l`). The password is for local development and is surfaced
580/// through `lightshuttle ps`; production export still requires an
581/// explicit password.
582fn generate_random_password() -> String {
583    use rand::Rng;
584
585    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
586    const LEN: usize = 24;
587
588    let mut rng = rand::rng();
589    (0..LEN)
590        .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char)
591        .collect()
592}
593
594#[cfg(test)]
595mod tests {
596    use super::{
597        VolumeSource, generate_random_password, parse_command, parse_duration, parse_port_string,
598        parse_volume_string,
599    };
600    use lightshuttle_manifest::Command;
601    use std::time::Duration;
602
603    #[test]
604    fn parse_port_string_two_part() {
605        let b = parse_port_string("8080:80").unwrap();
606        assert_eq!(b.host_port, 8080);
607        assert_eq!(b.container_port, 80);
608        assert_eq!(b.host_address, None);
609    }
610
611    #[test]
612    fn parse_port_string_three_part() {
613        let b = parse_port_string("127.0.0.1:8080:80").unwrap();
614        assert_eq!(b.host_port, 8080);
615        assert_eq!(b.container_port, 80);
616        assert_eq!(b.host_address.as_deref(), Some("127.0.0.1"));
617    }
618
619    #[test]
620    fn parse_port_string_single_part_is_error() {
621        assert!(parse_port_string("80").is_err());
622    }
623
624    #[test]
625    fn parse_port_string_non_numeric_is_error() {
626        assert!(parse_port_string("abc:80").is_err());
627    }
628
629    #[test]
630    fn parse_volume_string_named() {
631        let b = parse_volume_string("data:/var/lib/data").unwrap();
632        assert!(matches!(b.source, VolumeSource::Named(_)));
633        assert_eq!(b.target, "/var/lib/data");
634    }
635
636    #[test]
637    fn parse_volume_string_relative_host() {
638        let b = parse_volume_string("./src:/app").unwrap();
639        assert!(matches!(b.source, VolumeSource::HostPath(_)));
640        assert_eq!(b.target, "/app");
641    }
642
643    #[test]
644    fn parse_volume_string_absolute_host() {
645        let b = parse_volume_string("/abs/path:/app").unwrap();
646        assert!(matches!(b.source, VolumeSource::HostPath(_)));
647        assert_eq!(b.target, "/app");
648    }
649
650    #[test]
651    fn parse_volume_string_no_colon_is_error() {
652        assert!(parse_volume_string("nodatahere").is_err());
653    }
654
655    #[test]
656    fn parse_volume_string_braces_in_name_is_error() {
657        assert!(parse_volume_string("my{vol}:/data").is_err());
658    }
659
660    #[test]
661    fn parse_duration_seconds() {
662        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
663    }
664
665    #[test]
666    fn parse_duration_milliseconds() {
667        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
668    }
669
670    #[test]
671    fn parse_duration_minutes() {
672        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
673    }
674
675    #[test]
676    fn parse_duration_unknown_unit_is_error() {
677        assert!(parse_duration("10x").is_err());
678    }
679
680    #[test]
681    fn parse_duration_no_unit_is_error() {
682        assert!(parse_duration("10").is_err());
683    }
684
685    #[test]
686    fn parse_duration_no_digits_is_error() {
687        assert!(parse_duration("s").is_err());
688    }
689
690    #[test]
691    fn parse_command_empty_args_produces_empty_vec() {
692        assert!(parse_command(&Command::Args(vec![])).is_empty());
693    }
694
695    #[test]
696    fn parse_command_single_becomes_sh_c() {
697        let v = parse_command(&Command::Single("echo hi".to_owned()));
698        assert_eq!(v, vec!["sh", "-c", "echo hi"]);
699    }
700
701    #[test]
702    fn generated_password_has_expected_shape() {
703        let password = generate_random_password();
704        assert_eq!(password.len(), 24);
705        assert!(
706            password
707                .chars()
708                .all(|c| c.is_ascii_alphanumeric() && !"0O1Il".contains(c)),
709            "password must be unambiguous alphanumeric, got `{password}`"
710        );
711    }
712
713    #[test]
714    fn generated_passwords_are_distinct() {
715        // A clock-seeded generator would collide for calls within the
716        // same instant; a CSPRNG must not.
717        let first = generate_random_password();
718        let second = generate_random_password();
719        assert_ne!(first, second);
720    }
721}