Skip to main content

qk_executor/
readiness.rs

1//! A continuous target's `readyWhen`, as Nx 24 checks it: a url to request, a
2//! port to connect to, a command that must succeed, or text to find in the
3//! output. Tasks that depend on the target with `waitFor: "ready"` start once
4//! the check passes.
5//!
6//! This is unrelated to the run-commands option of the same name, which marks
7//! the run-commands task itself as ready.
8
9use std::collections::BTreeMap;
10use std::ffi::OsString;
11use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream, ToSocketAddrs, UdpSocket};
12use std::path::Path;
13use std::process::Stdio;
14use std::sync::Mutex;
15use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
16use std::time::{Duration, Instant};
17
18use anyhow::Result;
19use command_group::CommandGroup;
20use serde_json::{Map, Value};
21
22use crate::tokens::{closing_brace, env_name, fallbacks};
23
24// As in Nx 24.0.0-rc.0, long enough for a dev server's cold first build.
25pub const DEFAULT_TIMEOUT_MS: u64 = 600_000;
26/// As in Nx 24.0.0-beta.3, probes retry every 250 ms unless `interval` says
27/// otherwise.
28const DEFAULT_INTERVAL_MS: u64 = 250;
29const MAX_DURATION_MS: u64 = 2_147_483_647;
30/// How often a wait looks at whether it has been stopped or decided.
31const POLL: Duration = Duration::from_millis(20);
32
33/// What tells that a task is ready.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub enum Probe {
36    /// A GET answered with a status from 200 to 403.
37    Url(String),
38    /// A TCP connection, to loopback unless a host is named.
39    Port { port: u16, host: Option<String> },
40    /// A shell command at the workspace root exiting with 0, run with the
41    /// environment the task's commands run with and its options as JSON in
42    /// `NX_TASK_OPTIONS`, as in Nx 24.
43    Command {
44        command: String,
45        env: BTreeMap<OsString, OsString>,
46    },
47    /// Every one of these appearing in the task's output.
48    LogMatches(Vec<String>),
49    /// The task reporting it is ready, through the socket qk names to it in
50    /// `NX_READY_SIGNAL_SOCKET`, as `@nx/devkit`'s `signalTaskReady` does.
51    Signal,
52}
53
54/// A resolved `readyWhen`.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct ReadyCheck {
57    pub probe: Probe,
58    pub timeout: Duration,
59    pub interval: Duration,
60}
61
62impl ReadyCheck {
63    pub fn describe(&self) -> String {
64        match &self.probe {
65            Probe::Url(url) => format!("url {url}"),
66            Probe::Port {
67                port,
68                host: Some(host),
69            } => format!("port {host}:{port}"),
70            Probe::Port { port, host: None } => format!("port {port}"),
71            Probe::Command { command, .. } => format!("command \"{command}\""),
72            Probe::LogMatches(matches) => format!(
73                "logMatches {}",
74                matches
75                    .iter()
76                    .map(|text| format!("\"{text}\""))
77                    .collect::<Vec<_>>()
78                    .join(", ")
79            ),
80            Probe::Signal => "signal".into(),
81        }
82    }
83}
84
85const PENDING: u8 = 0;
86const READY: u8 = 1;
87const FAILED: u8 = 2;
88
89/// What a task's readiness check concluded, shared between the check, the
90/// task's output and the scheduler. The first conclusion stands.
91#[derive(Debug, Default)]
92pub struct Verdict {
93    state: AtomicU8,
94    reason: Mutex<Option<String>>,
95    /// The address a `signal` check's task reported with its readiness.
96    url: Mutex<Option<String>>,
97}
98
99impl Verdict {
100    pub fn is_ready(&self) -> bool {
101        self.state.load(Ordering::SeqCst) == READY
102    }
103
104    pub fn is_decided(&self) -> bool {
105        self.state.load(Ordering::SeqCst) != PENDING
106    }
107
108    /// Why the check failed, once it has.
109    pub fn failure(&self) -> Option<String> {
110        (self.state.load(Ordering::SeqCst) == FAILED)
111            .then(|| self.reason.lock().unwrap().clone().unwrap_or_default())
112    }
113
114    pub fn set_ready(&self) {
115        let _ = self
116            .state
117            .compare_exchange(PENDING, READY, Ordering::SeqCst, Ordering::SeqCst);
118    }
119
120    /// Ready, with the address the task reported, unless it was decided
121    /// already.
122    pub fn set_ready_at(&self, url: Option<String>) {
123        let mut stored = self.url.lock().unwrap();
124        if self
125            .state
126            .compare_exchange(PENDING, READY, Ordering::SeqCst, Ordering::SeqCst)
127            .is_ok()
128        {
129            *stored = url;
130        }
131    }
132
133    /// The address the task reported once ready, if it reported one.
134    pub fn url(&self) -> Option<String> {
135        self.is_ready()
136            .then(|| self.url.lock().unwrap().clone())
137            .flatten()
138    }
139
140    /// Records a failure unless the check was decided already, and says
141    /// whether this call decided it.
142    pub fn fail(&self, reason: String) -> bool {
143        let mut stored = self.reason.lock().unwrap();
144        let decided = self
145            .state
146            .compare_exchange(PENDING, FAILED, Ordering::SeqCst, Ordering::SeqCst)
147            .is_ok();
148        if decided {
149            *stored = Some(reason);
150        }
151        decided
152    }
153}
154
155/// What `readyWhen` placeholders read.
156pub struct Scope<'a> {
157    pub workspace_root: &'a str,
158    pub project_root: &'a str,
159    pub project_name: &'a str,
160    /// The options the task runs with, its arguments applied.
161    pub options: &'a Map<String, Value>,
162    /// The environment the task runs with.
163    pub env: &'a BTreeMap<String, String>,
164}
165
166/// How a placeholder treats what it reads.
167#[derive(Clone, Copy, PartialEq, Eq)]
168enum Field {
169    Text,
170    /// `true` and `false`, which a flag without a value gives, are unset.
171    Host,
172    /// As `Host`, and an empty value is `0`, a port the server picks.
173    Port,
174}
175
176/// Resolves a target's `readyWhen`: `None` when the server picks its own
177/// port, which leaves nothing to check.
178pub fn resolve(task: &str, ready_when: &Value, scope: &Scope) -> Result<Option<ReadyCheck>> {
179    let invalid = |reason: String| -> anyhow::Error {
180        anyhow::anyhow!("Task \"{task}\" has an invalid \"readyWhen\": {reason}.")
181    };
182    let Value::Object(config) = ready_when else {
183        return Err(invalid("expected an object".into()));
184    };
185    for key in config.keys() {
186        if !matches!(
187            key.as_str(),
188            "url" | "port" | "host" | "command" | "logMatches" | "signal" | "timeout" | "interval"
189        ) {
190            return Err(invalid(format!("unknown key \"{key}\"")));
191        }
192    }
193    let present: Vec<&str> = ["url", "port", "command", "logMatches", "signal"]
194        .into_iter()
195        .filter(|key| config.get(*key).is_some_and(|value| !value.is_null()))
196        .collect();
197    let [kind] = present[..] else {
198        return Err(invalid(
199            "expected exactly one of \"url\", \"port\", \"command\", \"logMatches\", \"signal\""
200                .into(),
201        ));
202    };
203    let duration = |key: &str, default: Option<u64>| -> Result<Option<Duration>> {
204        match config.get(key).filter(|value| !value.is_null()) {
205            None => Ok(default.map(Duration::from_millis)),
206            Some(value) => match value
207                .as_u64()
208                .filter(|ms| (1..=MAX_DURATION_MS).contains(ms))
209            {
210                Some(ms) => Ok(Some(Duration::from_millis(ms))),
211                None => Err(invalid(format!(
212                    "\"{key}\" must be an integer from 1 to {MAX_DURATION_MS}"
213                ))),
214            },
215        }
216    };
217    let timeout = duration("timeout", Some(DEFAULT_TIMEOUT_MS))?.unwrap();
218    let interval = duration("interval", Some(DEFAULT_INTERVAL_MS))?.unwrap();
219    let text = |key: &str, field: Field| -> Result<Option<Value>> {
220        match config.get(key) {
221            None | Some(Value::Null) => Ok(None),
222            Some(Value::String(template)) => render(template, scope, field)
223                .map(|text| Some(Value::String(text)))
224                .map_err(invalid),
225            Some(other) => Ok(Some(other.clone())),
226        }
227    };
228    let probe = match kind {
229        "url" => match text("url", Field::Text)? {
230            Some(Value::String(url))
231                if (url.starts_with("http://") || url.starts_with("https://"))
232                    && url.parse::<ureq::http::Uri>().is_ok() =>
233            {
234                Probe::Url(url)
235            }
236            _ => return Err(invalid("\"url\" must be an http or https URL".into())),
237        },
238        "port" => {
239            let port = text("port", Field::Port)?;
240            let host = match text("host", Field::Host)? {
241                None => None,
242                Some(Value::String(host)) if !host.is_empty() && !host.contains(['{', '}']) => {
243                    Some(host)
244                }
245                Some(Value::String(host)) if host.contains(['{', '}']) => {
246                    return Err(invalid("\"host\" contains an unknown placeholder".into()));
247                }
248                _ => return Err(invalid("\"host\" must be a non-empty string".into())),
249            };
250            let port = match port {
251                Some(Value::String(port)) if port == "auto" => 0,
252                Some(Value::String(port)) if port.contains(['{', '}']) => {
253                    return Err(invalid("\"port\" contains an unknown placeholder".into()));
254                }
255                Some(Value::String(port)) => port.parse::<u64>().unwrap_or(u64::MAX),
256                Some(Value::Number(port)) => port.as_u64().unwrap_or(u64::MAX),
257                _ => u64::MAX,
258            };
259            match port {
260                // The server picks its port when it starts, so nothing can be
261                // checked: it is ready once started.
262                0 => return Ok(None),
263                1..=65535 => Probe::Port {
264                    port: port as u16,
265                    host,
266                },
267                _ => {
268                    return Err(invalid(
269                        "\"port\" must be an integer from 0 to 65535, or \"auto\"".into(),
270                    ));
271                }
272            }
273        }
274        "signal" => {
275            if config["signal"] != Value::Bool(true) {
276                return Err(invalid("\"signal\" must be true".into()));
277            }
278            if config.get("interval").is_some_and(|value| !value.is_null()) {
279                return Err(invalid("\"interval\" is not used by \"signal\"".into()));
280            }
281            Probe::Signal
282        }
283        "command" => match text("command", Field::Text)? {
284            Some(Value::String(command)) if !command.is_empty() => Probe::Command {
285                command,
286                env: BTreeMap::new(),
287            },
288            _ => return Err(invalid("\"command\" must be a non-empty string".into())),
289        },
290        _ => {
291            let templates = match &config["logMatches"] {
292                Value::Array(templates) => templates.clone(),
293                single => vec![single.clone()],
294            };
295            let mut matches = Vec::new();
296            for template in &templates {
297                match template {
298                    Value::String(template) if !template.is_empty() => {
299                        matches.push(render(template, scope, Field::Text).map_err(invalid)?);
300                    }
301                    _ => {
302                        return Err(invalid(
303                            "\"logMatches\" must be a non-empty string or an array of them".into(),
304                        ));
305                    }
306                }
307            }
308            if matches.is_empty() || matches.iter().any(String::is_empty) {
309                return Err(invalid(
310                    "\"logMatches\" must be a non-empty string or an array of them".into(),
311                ));
312            }
313            Probe::LogMatches(matches)
314        }
315    };
316    Ok(Some(ReadyCheck {
317        probe,
318        timeout,
319        interval,
320    }))
321}
322
323/// A `readyWhen` string with its placeholders replaced. Braces that are no
324/// placeholder stay as written.
325fn render(template: &str, scope: &Scope, field: Field) -> std::result::Result<String, String> {
326    let mut result = String::new();
327    let mut index = 0;
328    while let Some(offset) = template[index..].find('{') {
329        let open = index + offset;
330        let resolved = match closing_brace(template, open) {
331            Some(close) => {
332                placeholder(&template[open..=close], scope, field)?.map(|value| (value, close))
333            }
334            None => None,
335        };
336        match resolved {
337            Some((value, close)) => {
338                result.push_str(&template[index..open]);
339                result.push_str(&value);
340                index = close + 1;
341            }
342            None => {
343                result.push_str(&template[index..=open]);
344                index = open + 1;
345            }
346        }
347    }
348    result.push_str(&template[index..]);
349    Ok(result)
350}
351
352/// The value a `{…}` placeholder stands for, or `None` when it is no
353/// placeholder.
354fn placeholder(
355    raw: &str,
356    scope: &Scope,
357    field: Field,
358) -> std::result::Result<Option<String>, String> {
359    let sources = fallbacks(&raw[1..raw.len() - 1]);
360    let read = |source: &str| -> Option<Option<Value>> {
361        if let Some(name) = env_name(source) {
362            return Some(scope.env.get(name).cloned().map(Value::String));
363        }
364        if let Some(path) = source.strip_prefix("options.") {
365            let mut value = Some(&Value::Object(scope.options.clone()));
366            let mut owned = None;
367            for key in path.split('.') {
368                owned = value.and_then(|value| value.get(key)).cloned();
369                value = owned.as_ref();
370            }
371            return Some(owned);
372        }
373        match source {
374            "workspaceRoot" => Some(Some(Value::String(scope.workspace_root.into()))),
375            "projectRoot" => Some(Some(Value::String(scope.project_root.into()))),
376            "projectName" => Some(Some(Value::String(scope.project_name.into()))),
377            _ => None,
378        }
379    };
380    if read(sources[0]).is_none() {
381        return Ok(None);
382    }
383    for (index, source) in sources.iter().enumerate() {
384        let value = match read(source) {
385            Some(value) => value,
386            None if index + 1 == sources.len() => {
387                Some(Value::String(render(source, scope, Field::Text)?))
388            }
389            None => {
390                return Err(format!(
391                    "\"{raw}\" has a fallback after the literal \"{source}\" that can never be used"
392                ));
393            }
394        };
395        let value = match value {
396            Some(Value::String(text)) if text.is_empty() && field == Field::Port => {
397                Value::Number(0.into())
398            }
399            Some(value) => value,
400            None => continue,
401        };
402        let unset = match &value {
403            Value::Null => true,
404            Value::String(text) => {
405                text.is_empty()
406                    || (field != Field::Text && matches!(text.as_str(), "true" | "false"))
407            }
408            Value::Bool(_) => field != Field::Text,
409            _ => false,
410        };
411        if unset {
412            continue;
413        }
414        return match value {
415            Value::String(text) => Ok(Some(text)),
416            Value::Number(number) => Ok(Some(number.to_string())),
417            Value::Bool(flag) => Ok(Some(flag.to_string())),
418            _ => Err(format!("\"{raw}\" is not a string or a number")),
419        };
420    }
421    Err(format!("\"{raw}\" has no value"))
422}
423
424/// Checks until the task is ready, the check times out, or `stop` is set,
425/// and records the conclusion in `verdict`. `logMatches` is decided by the
426/// task's output, so this only times it out.
427pub fn wait(task: &str, check: &ReadyCheck, root: &Path, verdict: &Verdict, stop: &AtomicBool) {
428    let deadline = Instant::now() + check.timeout;
429    loop {
430        if verdict.is_decided() || stop.load(Ordering::SeqCst) {
431            return;
432        }
433        let ready = match &check.probe {
434            Probe::Url(url) => probe_url(url, deadline),
435            Probe::Port { port, host } => probe_port(*port, host.as_deref(), deadline),
436            Probe::Command { command, env } => probe_command(command, env, root, deadline, stop),
437            // Decided by the task's output, or by its signal.
438            Probe::LogMatches(_) | Probe::Signal => false,
439        };
440        if ready {
441            verdict.set_ready();
442            return;
443        }
444        let now = Instant::now();
445        if now >= deadline {
446            let longer = (check.timeout.as_millis() as u64 * 2).min(MAX_DURATION_MS);
447            verdict.fail(format!(
448                "Task \"{task}\" did not become ready within {}ms (readyWhen: {}). If it needs \
449                 longer, set \"timeout\": {longer} in the target's \"readyWhen\", as \
450                 {{ \"...\": true, \"timeout\": {longer} }} to keep a check a target default \
451                 sets. To change the check, set \"readyWhen\" on the target or in \
452                 \"targetDefaults\" in nx.json, or set it to null to turn it off.",
453                check.timeout.as_millis(),
454                check.describe()
455            ));
456            return;
457        }
458        let pause = if matches!(check.probe, Probe::LogMatches(_) | Probe::Signal) {
459            deadline - now
460        } else {
461            check.interval.min(deadline - now)
462        };
463        let until = now + pause;
464        while Instant::now() < until {
465            if verdict.is_decided() || stop.load(Ordering::SeqCst) {
466                return;
467            }
468            std::thread::sleep(POLL.min(until.saturating_duration_since(Instant::now())));
469        }
470    }
471}
472
473fn unbracketed(host: &str) -> &str {
474    host.trim_start_matches('[').trim_end_matches(']')
475}
476
477/// A wildcard means loopback of its own family, as Nx probes it.
478fn destination(ip: IpAddr) -> IpAddr {
479    match ip.to_canonical() {
480        IpAddr::V4(v4) if v4.is_unspecified() => Ipv4Addr::LOCALHOST.into(),
481        IpAddr::V6(v6) if v6.is_unspecified() => Ipv6Addr::LOCALHOST.into(),
482        _ => ip,
483    }
484}
485
486fn is_loopback(ip: IpAddr) -> bool {
487    match ip {
488        IpAddr::V4(ip) => ip.is_loopback(),
489        IpAddr::V6(ip) => {
490            ip.is_loopback() || ip.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
491        }
492    }
493}
494
495/// The address webpack-dev-server and the Rspack dev server pick for the
496/// `local-ip` hosts: the machine's own outward address of that family, or
497/// loopback when it has none.
498fn local_ip(v6: bool) -> IpAddr {
499    let (bind, probe): (SocketAddr, SocketAddr) = if v6 {
500        (
501            (Ipv6Addr::UNSPECIFIED, 0).into(),
502            "[2001:4860:4860::8888]:53".parse().unwrap(),
503        )
504    } else {
505        (
506            (Ipv4Addr::UNSPECIFIED, 0).into(),
507            "8.8.8.8:53".parse().unwrap(),
508        )
509    };
510    // Connecting a UDP socket sends nothing; it only picks the route.
511    UdpSocket::bind(bind)
512        .and_then(|socket| socket.connect(probe).and_then(|()| socket.local_addr()))
513        .map(|address| address.ip())
514        .unwrap_or(if v6 {
515            Ipv6Addr::LOCALHOST.into()
516        } else {
517            Ipv4Addr::LOCALHOST.into()
518        })
519}
520
521fn connects(address: SocketAddr, deadline: Instant) -> bool {
522    let left = deadline.saturating_duration_since(Instant::now());
523    !left.is_zero() && TcpStream::connect_timeout(&address, left).is_ok()
524}
525
526/// Without a host, either loopback address accepting is enough: a server
527/// bound to only one of them is still ready.
528fn probe_port(port: u16, host: Option<&str>, deadline: Instant) -> bool {
529    let addresses: Vec<SocketAddr> = match host.map(unbracketed) {
530        None => vec![
531            (Ipv4Addr::LOCALHOST, port).into(),
532            (Ipv6Addr::LOCALHOST, port).into(),
533        ],
534        Some("local-ip") => {
535            let ip = local_ip(false);
536            let ip = if ip.is_loopback() { local_ip(true) } else { ip };
537            vec![(ip, port).into()]
538        }
539        Some("local-ipv4") => vec![(local_ip(false), port).into()],
540        Some("local-ipv6") => vec![(local_ip(true), port).into()],
541        Some(host) => match host.parse::<IpAddr>() {
542            Ok(ip) => vec![(destination(ip), port).into()],
543            Err(_) => match (host, port).to_socket_addrs() {
544                Ok(addresses) => addresses.collect(),
545                Err(_) => return false,
546            },
547        },
548    };
549    addresses
550        .into_iter()
551        .any(|address| connects(address, deadline))
552}
553
554/// The url to request, a wildcard host rewritten to loopback, and whether it
555/// stays on this machine: a loopback address, or a `localhost` name whose
556/// every address is loopback.
557fn target(url: &str) -> Option<(String, bool)> {
558    let uri: ureq::http::Uri = url.parse().ok()?;
559    let host = uri.host()?;
560    let bare = unbracketed(host);
561    if let Ok(ip) = bare.parse::<IpAddr>() {
562        let ip = destination(ip);
563        let written = match ip {
564            IpAddr::V4(v4) => v4.to_string(),
565            IpAddr::V6(v6) => format!("[{v6}]"),
566        };
567        let url = url.replacen(host, &written, 1);
568        return Some((url, is_loopback(ip)));
569    }
570    let name = bare.trim_end_matches('.');
571    let local = (name == "localhost" || name.ends_with(".localhost"))
572        && (bare, uri.port_u16().unwrap_or(80))
573            .to_socket_addrs()
574            .is_ok_and(|addresses| {
575                let addresses: Vec<_> = addresses.collect();
576                !addresses.is_empty() && addresses.iter().all(|address| is_loopback(address.ip()))
577            });
578    Some((url.to_owned(), local))
579}
580
581/// Ready on a status from 200 to 403, a redirect included: an auth challenge
582/// or a forbidden root still means the server is up. A 404 at the root is
583/// tried again at `/index.html`, which may be all a static server answers.
584fn probe_url(url: &str, deadline: Instant) -> bool {
585    let Some((url, local)) = target(url) else {
586        return false;
587    };
588    // A request to this machine skips proxies and accepts a self-signed
589    // development certificate.
590    let (proxy, tls) = if local {
591        (
592            None,
593            ureq::tls::TlsConfig::builder().disable_verification(true),
594        )
595    } else {
596        (
597            proxy_for(&url, &env_var),
598            ureq::tls::TlsConfig::builder().root_certs(roots().clone()),
599        )
600    };
601    answers(&url, proxy, tls.build(), deadline)
602}
603
604/// Whether the url answers ready, through `proxy`, with `tls` deciding which
605/// certificates to trust.
606fn answers(
607    url: &str,
608    proxy: Option<ureq::Proxy>,
609    tls: ureq::tls::TlsConfig,
610    deadline: Instant,
611) -> bool {
612    let status = |url: &str| -> Option<u16> {
613        let left = deadline.saturating_duration_since(Instant::now());
614        if left.is_zero() {
615            return None;
616        }
617        let config = ureq::Agent::config_builder()
618            .max_redirects(0)
619            .http_status_as_error(false)
620            .timeout_global(Some(left))
621            .proxy(proxy.clone())
622            .tls_config(tls.clone());
623        let agent: ureq::Agent = config.build().into();
624        agent
625            .get(url)
626            .call()
627            .ok()
628            .map(|response| response.status().as_u16())
629    };
630    let ready = |status: u16| (200..=403).contains(&status);
631    match status(url) {
632        Some(404) => {
633            let uri: Option<ureq::http::Uri> = url.parse().ok();
634            if uri.as_ref().is_some_and(|uri| uri.path() == "/") {
635                let index = format!("{}/index.html", url.trim_end_matches('/'));
636                status(&index).is_some_and(ready)
637            } else {
638                false
639            }
640        }
641        Some(found) => ready(found),
642        None => false,
643    }
644}
645
646fn env_var(names: &[&str]) -> String {
647    names
648        .iter()
649        .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
650        .unwrap_or_default()
651}
652
653/// The proxy the environment sets for the url's scheme, unless `NO_PROXY`
654/// covers its host. qk matches `NO_PROXY` itself, since ureq's match misses
655/// ports, IP ranges and a domain's subdomains, and it ignores the scheme
656/// when it picks a proxy.
657fn proxy_for(url: &str, var: &dyn Fn(&[&str]) -> String) -> Option<ureq::Proxy> {
658    let uri: ureq::http::Uri = url.parse().ok()?;
659    let https = uri.scheme_str() == Some("https");
660    let port = uri.port_u16().unwrap_or(if https { 443 } else { 80 });
661    if no_proxy_matches(&var(&["NO_PROXY", "no_proxy"]), uri.host()?, port) {
662        return None;
663    }
664    let proxy = if https {
665        var(&["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"])
666    } else {
667        var(&["HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"])
668    };
669    ureq::Proxy::new(&proxy).ok()
670}
671
672/// Whether an entry of `NO_PROXY` covers the host: `*`, a domain with its
673/// subdomains (`example.com` or `.example.com`), only the subdomains
674/// (`*.example.com`), an IP address, or a CIDR range. Any entry but a range
675/// can end in a port, to match only that port.
676fn no_proxy_matches(no_proxy: &str, host: &str, port: u16) -> bool {
677    let host = unbracketed(host).trim_end_matches('.');
678    let ip = host.parse::<IpAddr>().ok();
679    no_proxy.split(',').map(str::trim).any(|entry| {
680        if let Some((network, bits)) = entry.split_once('/') {
681            return ip.is_some_and(|ip| in_range(ip, network, bits));
682        }
683        let (pattern, entry_port) = split_port(entry);
684        if entry_port.is_some_and(|entry_port| entry_port != port) {
685            return false;
686        }
687        if pattern == "*" {
688            return true;
689        }
690        match ip {
691            Some(ip) => unbracketed(pattern)
692                .parse::<IpAddr>()
693                .is_ok_and(|entry| entry.to_canonical() == ip.to_canonical()),
694            None => match pattern.strip_prefix("*.") {
695                Some(domain) => is_subdomain(host, domain),
696                None => {
697                    let domain = pattern.strip_prefix('.').unwrap_or(pattern);
698                    !domain.is_empty()
699                        && (host.eq_ignore_ascii_case(domain) || is_subdomain(host, domain))
700                }
701            },
702        }
703    })
704}
705
706/// An unbracketed IPv6 address has no port: its last group would read as one.
707fn split_port(entry: &str) -> (&str, Option<u16>) {
708    match entry.rsplit_once(':') {
709        Some((host, port)) if host.starts_with('[') || !host.contains(':') => match port.parse() {
710            Ok(port) => (host, Some(port)),
711            Err(_) => (entry, None),
712        },
713        _ => (entry, None),
714    }
715}
716
717fn is_subdomain(host: &str, domain: &str) -> bool {
718    let (host, domain) = (host.to_ascii_lowercase(), domain.to_ascii_lowercase());
719    !domain.is_empty()
720        && host
721            .strip_suffix(&domain)
722            .is_some_and(|rest| rest.ends_with('.'))
723}
724
725fn in_range(ip: IpAddr, network: &str, bits: &str) -> bool {
726    let (Ok(network), Ok(bits)) = (unbracketed(network).parse::<IpAddr>(), bits.parse::<u32>())
727    else {
728        return false;
729    };
730    match (ip.to_canonical(), network.to_canonical()) {
731        (IpAddr::V4(ip), IpAddr::V4(network)) if bits <= 32 => {
732            let mask = u32::MAX.checked_shl(32 - bits).unwrap_or(0);
733            u32::from(ip) & mask == u32::from(network) & mask
734        }
735        (IpAddr::V6(ip), IpAddr::V6(network)) if bits <= 128 => {
736            let mask = u128::MAX.checked_shl(128 - bits).unwrap_or(0);
737            u128::from(ip) & mask == u128::from(network) & mask
738        }
739        _ => false,
740    }
741}
742
743/// The certificates a url probe trusts: the operating system's, which
744/// `SSL_CERT_FILE` or `SSL_CERT_DIR` replace, and those in the file that
745/// `NODE_EXTRA_CA_CERTS` names, as Node reads it. A file that can't be read
746/// adds none. With no certificate from either, the roots built into qk.
747/// Loaded once per run, as Node reads them once per process.
748fn roots() -> &'static ureq::tls::RootCerts {
749    static ROOTS: std::sync::OnceLock<ureq::tls::RootCerts> = std::sync::OnceLock::new();
750    ROOTS.get_or_init(|| {
751        let mut certs: Vec<ureq::tls::Certificate<'static>> =
752            rustls_native_certs::load_native_certs()
753                .certs
754                .iter()
755                .map(|der| ureq::tls::Certificate::from_der(der).to_owned())
756                .collect();
757        if let Some(path) = std::env::var_os("NODE_EXTRA_CA_CERTS") {
758            certs.extend(extra_roots(Path::new(&path)));
759        }
760        if certs.is_empty() {
761            ureq::tls::RootCerts::WebPki
762        } else {
763            ureq::tls::RootCerts::new_with_certs(&certs)
764        }
765    })
766}
767
768/// The certificates in a PEM file, up to a section that doesn't parse.
769fn extra_roots(path: &Path) -> Vec<ureq::tls::Certificate<'static>> {
770    let Ok(pem) = std::fs::read(path) else {
771        return Vec::new();
772    };
773    ureq::tls::parse_pem(&pem)
774        .map_while(Result::ok)
775        .filter_map(|item| match item {
776            ureq::tls::PemItem::Certificate(cert) => Some(cert),
777            _ => None,
778        })
779        .collect()
780}
781
782/// Runs the command through the shell at the workspace root, with `env` in
783/// place of qk's own when it has any. On timeout or stop, its whole process
784/// group is killed.
785fn probe_command(
786    command: &str,
787    env: &BTreeMap<OsString, OsString>,
788    root: &Path,
789    deadline: Instant,
790    stop: &AtomicBool,
791) -> bool {
792    let mut shell = crate::shell(command);
793    if !env.is_empty() {
794        shell.env_clear().envs(env);
795    }
796    shell
797        .current_dir(root)
798        .stdin(Stdio::null())
799        .stdout(Stdio::null())
800        .stderr(Stdio::null());
801    let Ok(mut child) = shell.group_spawn() else {
802        return false;
803    };
804    loop {
805        match child.try_wait() {
806            Ok(Some(status)) => return status.success(),
807            Ok(None) => {}
808            Err(_) => break,
809        }
810        if Instant::now() >= deadline || stop.load(Ordering::SeqCst) {
811            break;
812        }
813        std::thread::sleep(POLL);
814    }
815    let _ = child.kill();
816    let _ = child.wait();
817    false
818}
819
820/// Watches a task's output for `logMatches` text, in any order, on either
821/// stream and across reads. Terminal control sequences are left out, as a
822/// terminal would not show them.
823#[derive(Debug)]
824pub struct LogMatcher {
825    pending: Vec<String>,
826    /// Each stream's visible text, kept as long as the longest pending match.
827    tails: [String; 2],
828    /// Each stream's place in a control sequence a read ended inside.
829    states: [Escape; 2],
830}
831
832#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
833enum Escape {
834    #[default]
835    Text,
836    /// After ESC.
837    Start,
838    /// In a CSI sequence, up to its final byte.
839    Csi,
840    /// In an OSC, DCS, SOS, PM or APC string, up to BEL or ST.
841    Control,
842    /// After ESC inside such a string, which ST ends.
843    ControlEnd,
844}
845
846impl LogMatcher {
847    pub fn new(patterns: Vec<String>) -> Self {
848        Self {
849            pending: patterns,
850            tails: Default::default(),
851            states: Default::default(),
852        }
853    }
854
855    /// Feeds one read; true once every pattern has appeared.
856    pub fn feed(&mut self, stderr: bool, bytes: &[u8]) -> bool {
857        let stream = usize::from(stderr);
858        let text = String::from_utf8_lossy(bytes);
859        let (tail, state) = (&mut self.tails[stream], &mut self.states[stream]);
860        for c in text.chars() {
861            *state = match (*state, c) {
862                (Escape::Text, '\u{1b}') => Escape::Start,
863                (Escape::Text, c) => {
864                    tail.push(c);
865                    Escape::Text
866                }
867                (Escape::Start, '[') => Escape::Csi,
868                (Escape::Start, ']' | 'P' | 'X' | '^' | '_') => Escape::Control,
869                (Escape::Start, _) => Escape::Text,
870                (Escape::Csi, '\u{40}'..='\u{7e}') => Escape::Text,
871                (Escape::Csi, _) => Escape::Csi,
872                (Escape::Control, '\u{7}') => Escape::Text,
873                (Escape::Control, '\u{1b}') => Escape::ControlEnd,
874                (Escape::Control, _) => Escape::Control,
875                (Escape::ControlEnd, '\\') => Escape::Text,
876                (Escape::ControlEnd, _) => Escape::Control,
877            };
878        }
879        self.pending
880            .retain(|pattern| !tail.contains(pattern.as_str()));
881        let keep = self
882            .pending
883            .iter()
884            .map(|pattern| pattern.len().saturating_sub(1))
885            .max()
886            .unwrap_or(0);
887        if tail.len() > keep {
888            let mut start = tail.len() - keep;
889            while !tail.is_char_boundary(start) {
890                start += 1;
891            }
892            tail.drain(..start);
893        }
894        self.pending.is_empty()
895    }
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use serde_json::json;
902
903    fn scope<'a>(options: &'a Map<String, Value>, env: &'a BTreeMap<String, String>) -> Scope<'a> {
904        Scope {
905            workspace_root: "/repo",
906            project_root: "apps/app",
907            project_name: "app",
908            options,
909            env,
910        }
911    }
912
913    fn resolved(
914        ready_when: Value,
915        options: Value,
916        env: &[(&str, &str)],
917    ) -> Result<Option<ReadyCheck>> {
918        let options = options.as_object().unwrap().clone();
919        let env = env
920            .iter()
921            .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
922            .collect();
923        resolve("app:serve", &ready_when, &scope(&options, &env))
924    }
925
926    #[test]
927    fn placeholders_read_options_env_and_fallbacks() {
928        let check = resolved(
929            json!({"url": "http://localhost:{options.port ?? env.PORT ?? 4200}/{projectName}"}),
930            json!({}),
931            &[("PORT", "5001")],
932        )
933        .unwrap()
934        .unwrap();
935        assert_eq!(check.probe, Probe::Url("http://localhost:5001/app".into()));
936        assert_eq!(check.timeout, Duration::from_millis(600_000));
937        assert_eq!(check.interval, Duration::from_millis(250));
938        let check = resolved(
939            json!({"port": "{options.port}"}),
940            json!({"port": 3000}),
941            &[],
942        )
943        .unwrap()
944        .unwrap();
945        assert_eq!(
946            check.probe,
947            Probe::Port {
948                port: 3000,
949                host: None
950            }
951        );
952        let check = resolved(
953            json!({"logMatches": ["ready on {options.server.port}", "{other}"], "timeout": 5}),
954            json!({"server": {"port": 1}}),
955            &[],
956        )
957        .unwrap()
958        .unwrap();
959        assert_eq!(
960            check.probe,
961            Probe::LogMatches(vec!["ready on 1".into(), "{other}".into()])
962        );
963        assert_eq!(check.timeout, Duration::from_millis(5));
964    }
965
966    #[test]
967    fn a_port_the_server_picks_leaves_nothing_to_check() {
968        for port in [json!(0), json!("auto"), json!("{options.port}")] {
969            let options = json!({"port": ""});
970            assert_eq!(resolved(json!({"port": port}), options, &[]).unwrap(), None);
971        }
972    }
973
974    #[test]
975    fn a_flag_without_a_value_leaves_host_and_port_unset() {
976        let check = resolved(
977            json!({"port": "{options.port ?? 4200}", "host": "{options.host ?? env.HOST}"}),
978            json!({"port": "true", "host": "true"}),
979            &[],
980        );
981        assert!(format!("{:#}", check.unwrap_err()).contains("has no value"));
982        let check = resolved(
983            json!({"port": "{options.port ?? 4200}"}),
984            json!({"port": true}),
985            &[],
986        )
987        .unwrap()
988        .unwrap();
989        assert_eq!(
990            check.probe,
991            Probe::Port {
992                port: 4200,
993                host: None
994            }
995        );
996    }
997
998    #[test]
999    fn a_signal_waits_for_the_task_to_report() {
1000        let check = resolved(json!({"signal": true, "timeout": 5}), json!({}), &[])
1001            .unwrap()
1002            .unwrap();
1003        assert_eq!(
1004            (check.probe.clone(), check.describe()),
1005            (Probe::Signal, "signal".into())
1006        );
1007        // Nothing but the task's own report makes it ready.
1008        let verdict = Verdict::default();
1009        wait(
1010            "app:serve",
1011            &check,
1012            Path::new("."),
1013            &verdict,
1014            &AtomicBool::new(false),
1015        );
1016        assert!(
1017            verdict
1018                .failure()
1019                .unwrap()
1020                .contains("did not become ready within 5ms")
1021        );
1022        let verdict = Verdict::default();
1023        verdict.set_ready_at(Some("http://localhost:1/".into()));
1024        verdict.set_ready_at(Some("http://localhost:2/".into()));
1025        assert_eq!(verdict.url().as_deref(), Some("http://localhost:1/"));
1026    }
1027
1028    #[test]
1029    fn invalid_settings_say_why() {
1030        for (ready_when, reason) in [
1031            (json!({}), "exactly one of"),
1032            (json!({"url": "a", "port": 1}), "exactly one of"),
1033            (json!({"url": "ftp://x"}), "http or https"),
1034            (json!({"port": 70000}), "from 0 to 65535"),
1035            (json!({"port": "{options.missing}"}), "has no value"),
1036            (json!({"command": ""}), "non-empty"),
1037            (json!({"logMatches": []}), "logMatches"),
1038            (json!({"port": 1, "timeout": 0}), "\"timeout\""),
1039            (json!({"port": 1, "other": 0}), "unknown key"),
1040            (json!({"signal": false}), "\"signal\" must be true"),
1041            (json!({"signal": "yes"}), "\"signal\" must be true"),
1042            (
1043                json!({"signal": true, "interval": 10}),
1044                "not used by \"signal\"",
1045            ),
1046            (json!({"signal": true, "port": 1}), "exactly one of"),
1047            (
1048                json!({"url": "http://{options.a ?? x ?? options.b}"}),
1049                "can never be used",
1050            ),
1051        ] {
1052            let error = resolved(ready_when.clone(), json!({}), &[]).unwrap_err();
1053            assert!(
1054                format!("{error:#}").contains(reason),
1055                "{ready_when}: {error:#}"
1056            );
1057        }
1058    }
1059
1060    #[test]
1061    fn log_matcher_needs_every_pattern_without_control_sequences() {
1062        let mut matcher = LogMatcher::new(vec!["ready".into(), "listening".into()]);
1063        assert!(!matcher.feed(false, b"server listening on 3000"));
1064        assert!(matcher.feed(true, b"\x1b[32mrea\x1b[0mdy"));
1065        let mut matcher = LogMatcher::new(vec!["ready".into()]);
1066        assert!(!matcher.feed(false, b"rea\x1b["));
1067        assert!(matcher.feed(false, b"32mdy"));
1068        let mut matcher = LogMatcher::new(vec!["ready".into()]);
1069        assert!(!matcher.feed(false, b"\x1b]0;ready\x07"));
1070        assert!(matcher.feed(false, b"rea\x1b]8;;http://x\x1b\\dy"));
1071        let mut matcher = LogMatcher::new(vec!["éready".into()]);
1072        assert!(!matcher.feed(false, "éééa".as_bytes()));
1073        assert!(matcher.feed(false, "éready".as_bytes()));
1074    }
1075
1076    #[test]
1077    fn port_and_command_probes_wait_for_readiness() {
1078        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1079        let port = listener.local_addr().unwrap().port();
1080        let stop = AtomicBool::new(false);
1081        for host in [None, Some("127.0.0.1"), Some("0.0.0.0")] {
1082            let verdict = Verdict::default();
1083            let check = ReadyCheck {
1084                probe: Probe::Port {
1085                    port,
1086                    host: host.map(str::to_owned),
1087                },
1088                timeout: Duration::from_secs(5),
1089                interval: Duration::from_millis(10),
1090            };
1091            wait("app:serve", &check, Path::new("."), &verdict, &stop);
1092            assert!(verdict.is_ready(), "{host:?}");
1093        }
1094        drop(listener);
1095        let verdict = Verdict::default();
1096        let check = ReadyCheck {
1097            probe: Probe::Command {
1098                command: "exit 1".into(),
1099                env: BTreeMap::new(),
1100            },
1101            timeout: Duration::from_millis(100),
1102            interval: Duration::from_millis(10),
1103        };
1104        wait("app:serve", &check, Path::new("."), &verdict, &stop);
1105        assert!(
1106            verdict
1107                .failure()
1108                .unwrap()
1109                .contains("did not become ready within 100ms")
1110        );
1111        let verdict = Verdict::default();
1112        let check = ReadyCheck {
1113            probe: Probe::Command {
1114                command: "exit 0".into(),
1115                env: BTreeMap::new(),
1116            },
1117            ..check
1118        };
1119        wait("app:serve", &check, Path::new("."), &verdict, &stop);
1120        assert!(verdict.is_ready());
1121    }
1122
1123    #[test]
1124    fn url_probes_take_statuses_up_to_403_and_try_index_html() {
1125        use std::io::{Read, Write};
1126        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1127        let port = listener.local_addr().unwrap().port();
1128        std::thread::spawn(move || {
1129            for mut stream in listener.incoming().flatten() {
1130                let mut request = Vec::new();
1131                let mut byte = [0u8; 1];
1132                while stream.read(&mut byte).is_ok_and(|n| n == 1) {
1133                    request.push(byte[0]);
1134                    if request.ends_with(b"\r\n\r\n") {
1135                        break;
1136                    }
1137                }
1138                let request = String::from_utf8_lossy(&request);
1139                let status = if request.starts_with("GET /index.html") {
1140                    "200 OK"
1141                } else if request.starts_with("GET /missing") || request.starts_with("GET / ") {
1142                    "404 Not Found"
1143                } else {
1144                    "302 Found\r\nlocation: /elsewhere"
1145                };
1146                let _ = stream.write_all(
1147                    format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\n\r\n").as_bytes(),
1148                );
1149            }
1150        });
1151        let deadline = Instant::now() + Duration::from_secs(5);
1152        assert!(probe_url(&format!("http://127.0.0.1:{port}/"), deadline));
1153        assert!(probe_url(
1154            &format!("http://0.0.0.0:{port}/redirects"),
1155            deadline
1156        ));
1157        assert!(!probe_url(
1158            &format!("http://127.0.0.1:{port}/missing"),
1159            deadline
1160        ));
1161    }
1162
1163    #[test]
1164    fn no_proxy_takes_every_form_nx_documents() {
1165        for (no_proxy, host, port, matches) in [
1166            ("*", "app.example.com", 80, true),
1167            ("*", "10.0.0.5", 80, true),
1168            ("example.com", "example.com", 80, true),
1169            ("example.com", "a.b.example.com", 80, true),
1170            (".Example.COM", "app.example.com", 80, true),
1171            ("example.com", "badexample.com", 80, false),
1172            ("*.example.com", "app.example.com", 80, true),
1173            ("*.example.com", "example.com", 80, false),
1174            ("localhost, *.example.com", "app.example.com.", 80, true),
1175            ("app.example.com:8080", "app.example.com", 8080, true),
1176            ("app.example.com:8080", "app.example.com", 9090, false),
1177            ("*.example.com:443", "app.example.com", 443, true),
1178            ("10.0.0.5", "10.0.0.5", 80, true),
1179            ("10.0.0.5:8080", "10.0.0.6", 8080, false),
1180            ("[fd00::1]:8080", "[fd00::1]", 8080, true),
1181            ("fd00::1", "[fd00::1]", 80, true),
1182            ("fd00::1:2:3", "[fd00::1:2]", 3, false),
1183            ("10.0.0.0/8", "10.200.3.4", 80, true),
1184            ("10.0.0.0/8", "11.0.0.1", 80, false),
1185            ("10.0.0.0/8", "ten.example.com", 80, false),
1186            ("fd00::/16", "[fd00:1::5]", 80, true),
1187            ("0.0.0.0/0", "192.0.2.1", 80, true),
1188            ("", "example.com", 80, false),
1189            ("a.test, ", "example.com", 80, false),
1190        ] {
1191            assert_eq!(
1192                no_proxy_matches(no_proxy, host, port),
1193                matches,
1194                "{no_proxy:?} {host}:{port}"
1195            );
1196        }
1197    }
1198
1199    fn vars<'a>(set: &'a [(&'a str, &'a str)]) -> impl Fn(&[&str]) -> String + 'a {
1200        move |names: &[&str]| {
1201            names
1202                .iter()
1203                .find_map(|name| set.iter().find(|(set, _)| set == name))
1204                .map(|(_, value)| value.to_string())
1205                .unwrap_or_default()
1206        }
1207    }
1208
1209    #[test]
1210    fn a_url_takes_the_proxy_for_its_scheme_unless_no_proxy_covers_it() {
1211        let proxy = |url: &str, set: &[(&str, &str)]| {
1212            proxy_for(url, &vars(set)).map(|proxy| proxy.uri().to_string())
1213        };
1214        let both = [
1215            ("HTTP_PROXY", "http://plain:3128"),
1216            ("https_proxy", "http://secure:3128"),
1217        ];
1218        assert_eq!(
1219            proxy("http://app.example.com/", &both).as_deref(),
1220            Some("http://plain:3128/")
1221        );
1222        assert_eq!(
1223            proxy("https://app.example.com/", &both).as_deref(),
1224            Some("http://secure:3128/")
1225        );
1226        assert_eq!(
1227            proxy(
1228                "https://app.example.com/",
1229                &[("HTTP_PROXY", "http://plain:3128")]
1230            ),
1231            None
1232        );
1233        assert_eq!(
1234            proxy(
1235                "https://app.example.com/",
1236                &[("ALL_PROXY", "http://any:3128")]
1237            )
1238            .as_deref(),
1239            Some("http://any:3128/")
1240        );
1241        let skipped = [
1242            ("HTTP_PROXY", "http://plain:3128"),
1243            ("NO_PROXY", "*.example.com"),
1244        ];
1245        assert_eq!(proxy("http://app.example.com/", &skipped), None);
1246        let other_port = [
1247            ("HTTP_PROXY", "http://plain:3128"),
1248            ("no_proxy", "app.example.com:8080"),
1249        ];
1250        assert!(proxy("http://app.example.com/", &other_port).is_some());
1251    }
1252
1253    /// A proxy that opens every tunnel asked of it, answers the request sent
1254    /// through it with 200 itself, and counts the connections.
1255    fn proxy_server(connections: std::sync::Arc<AtomicU8>) -> u16 {
1256        use std::io::{Read, Write};
1257        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1258        let port = listener.local_addr().unwrap().port();
1259        std::thread::spawn(move || {
1260            for mut stream in listener.incoming().flatten() {
1261                connections.fetch_add(1, Ordering::SeqCst);
1262                let head = |stream: &mut std::net::TcpStream| {
1263                    let mut head = Vec::new();
1264                    let mut byte = [0u8; 1];
1265                    while stream.read(&mut byte).is_ok_and(|n| n == 1) {
1266                        head.push(byte[0]);
1267                        if head.ends_with(b"\r\n\r\n") {
1268                            break;
1269                        }
1270                    }
1271                    head
1272                };
1273                if head(&mut stream).starts_with(b"CONNECT ") {
1274                    let _ = stream.write_all(b"HTTP/1.1 200 Connection established\r\n\r\n");
1275                    head(&mut stream);
1276                }
1277                let _ = stream.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n");
1278            }
1279        });
1280        port
1281    }
1282
1283    #[test]
1284    fn a_remote_url_is_requested_through_the_proxy() {
1285        let requests = std::sync::Arc::new(AtomicU8::new(0));
1286        let port = proxy_server(requests.clone());
1287        let proxy = format!("http://127.0.0.1:{port}");
1288        // Only the proxy can answer: the name never resolves.
1289        let url = "http://app.example.invalid:8080/";
1290        let deadline = || Instant::now() + Duration::from_secs(5);
1291        let tls = || ureq::tls::TlsConfig::builder().build();
1292        let through = proxy_for(url, &vars(&[("HTTP_PROXY", &proxy)]));
1293        assert!(answers(url, through, tls(), deadline()));
1294        assert_eq!(requests.load(Ordering::SeqCst), 1);
1295        let around = proxy_for(
1296            url,
1297            &vars(&[("HTTP_PROXY", &proxy), ("NO_PROXY", "*.example.invalid")]),
1298        );
1299        assert!(!answers(url, around, tls(), deadline()));
1300        assert_eq!(requests.load(Ordering::SeqCst), 1);
1301    }
1302
1303    #[test]
1304    fn a_url_trusts_the_certificates_node_extra_ca_certs_names() {
1305        use rustls::pki_types::pem::PemObject;
1306        use rustls::pki_types::{CertificateDer, PrivateKeyDer};
1307        use std::io::{Read, Write};
1308        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/readiness/fixtures");
1309        let config = std::sync::Arc::new(
1310            rustls::ServerConfig::builder_with_provider(std::sync::Arc::new(
1311                rustls::crypto::ring::default_provider(),
1312            ))
1313            .with_safe_default_protocol_versions()
1314            .unwrap()
1315            .with_no_client_auth()
1316            .with_single_cert(
1317                vec![CertificateDer::from_pem_file(fixtures.join("server.pem")).unwrap()],
1318                PrivateKeyDer::from_pem_file(fixtures.join("server.key")).unwrap(),
1319            )
1320            .unwrap(),
1321        );
1322        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1323        let port = listener.local_addr().unwrap().port();
1324        std::thread::spawn(move || {
1325            for mut stream in listener.incoming().flatten() {
1326                let Ok(connection) = rustls::ServerConnection::new(config.clone()) else {
1327                    continue;
1328                };
1329                let mut tls = rustls::StreamOwned::new(connection, &mut stream);
1330                let mut request = Vec::new();
1331                let mut byte = [0u8; 1];
1332                while tls.read(&mut byte).is_ok_and(|n| n == 1) {
1333                    request.push(byte[0]);
1334                    if request.ends_with(b"\r\n\r\n") {
1335                        let _ = tls.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n");
1336                        let _ = tls.flush();
1337                        break;
1338                    }
1339                }
1340            }
1341        });
1342        let url = format!("https://127.0.0.1:{port}/");
1343        let deadline = || Instant::now() + Duration::from_secs(5);
1344        let trusting =
1345            |roots: ureq::tls::RootCerts| ureq::tls::TlsConfig::builder().root_certs(roots).build();
1346        let extra = extra_roots(&fixtures.join("ca.pem"));
1347        assert_eq!(extra.len(), 1);
1348        assert!(answers(
1349            &url,
1350            None,
1351            trusting(ureq::tls::RootCerts::new_with_certs(&extra)),
1352            deadline()
1353        ));
1354        assert!(!answers(
1355            &url,
1356            None,
1357            trusting(ureq::tls::RootCerts::WebPki),
1358            deadline()
1359        ));
1360        assert!(extra_roots(&fixtures.join("absent.pem")).is_empty());
1361    }
1362}