Files
Codeman/packages/desktop/src/probe.rs
T
Codeman maintainer 8ba5134174 feat(desktop): native macOS app that runs, installs or connects to Codeman
Adds packages/desktop, a small native app (Rust: tao, wry/WKWebView, muda,
objc2) that opens Codeman in its own window. The universal app is about 5 MB
(3.2 MB as a DMG) and runs on macOS 11.3 or later.

- Connect screen with two tabs. This Mac finds the local Codeman and offers
  Open, Start or Install. Start uses launchd for a LaunchAgent install and
  codeman web -d otherwise. It never starts a second server while one runs,
  because two servers on one data dir share live sessions. Another machine
  connects by address (recent servers, Keychain logins) and can install
  Codeman there over SSH.
- Installs run Codeman's install.sh in Terminal from a self-deleting
  .command script. The installer needs a terminal for sudo, Homebrew and
  ssh. A local install uses --local --service and the first free port from
  3000, and the app opens Codeman once it answers.
- The local Codeman's login is read from its LaunchAgent or
  ~/.codeman/.env. It is sent only to 127.0.0.1 on that port, only while
  the user's own Codeman process holds it, and is never saved.
- Native JS dialogs (alert, confirm, prompt) and HTTP Basic challenge
  answers, both scoped to Codeman's own pages, so the page keeps working
  after a server restart. App cookies are cleared before every connect.
- Pop-outs through window.CodemanHost, with an allowlist (webview/ never
  opens a window), external links in the default browser, downloads to
  ~/Downloads, real full screen and window state that is remembered.
- The connect page refuses a WebKit older than Safari 16.4, which Codeman's
  terminal needs.

Codeman side: viewSubagentTranscript shows a toast when the pop-up is
refused instead of failing, with zh-CN strings and a test that pins them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 00:36:49 +01:00

728 lines
29 KiB
Rust

//! "Is a Codeman server answering at this address, and does it want a login?"
//!
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
//! The answer is classified into something the connect page can explain. It is
//! asked once per user action, never in a loop: Codeman counts a request
//! without valid credentials against the login rate limit (10 per IP).
use serde::Serialize;
use std::time::Duration;
use url::Url;
const TIMEOUT: Duration = Duration::from_secs(5);
const MAX_BODY: u64 = 8 * 1024 * 1024;
/// The path the probe asks for, relative to the server's base URL.
const STATUS_PATH: &str = "api/status";
/// rustls' text for `Error::InvalidCertificate`, which is what a handshake
/// with a certificate the OS does not trust fails with. ureq hands it over as
/// an `io::Error` of kind `InvalidData`, so the text is all there is to match
/// without a direct rustls dependency.
const CERT_REJECTED_PREFIX: &str = "invalid peer certificate";
/// std's text for a failed `getaddrinfo`. Its kind is `Uncategorized`, which
/// cannot be matched on stable Rust, so the prefix is the only handle. The
/// prefix is the same on macOS and Linux; only the resolver's reason differs.
const LOOKUP_FAILED_PREFIX: &str = "failed to lookup address information";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum ProbeOutcome {
Ok {
version: String,
},
/// 401 and we sent no credentials.
NeedsLogin,
/// 401 although we sent credentials.
BadCredentials,
/// 429: too many failed logins from this address. A single-user Codeman
/// still accepts the right password, so the page needs to know whether
/// this probe carried one. `retry_after_secs` is the server's
/// `Retry-After`, when it gave one in seconds.
#[serde(rename_all = "camelCase")]
RateLimited {
sent_credentials: bool,
#[serde(skip_serializing_if = "Option::is_none")]
retry_after_secs: Option<u64>,
},
/// 403 `PASSWORD_CHANGE_REQUIRED`: the credentials are valid, but a
/// multi-user Codeman wants this account's password changed first. Page
/// loads are exempt, so the web UI can show its change-password dialog.
PasswordChangeRequired,
/// 403 from Codeman's Host guard (a name it does not know, e.g. a custom domain).
HostNotAllowed,
/// 3xx. `location` is the target resolved against the probed URL.
/// `suggested` is a server address to use instead, set only when the
/// redirect stays on the same host, only moves to https or another port,
/// and still points at the status API. Never followed: a second request
/// would carry the credentials to an address the user did not type.
Redirect {
location: String,
suggested: Option<String>,
},
/// Something answered, but not Codeman's status API.
NotCodeman {
status: u16,
},
Refused,
/// No route to the address: the machine is off or on another network,
/// or macOS's Local Network privacy setting blocked the app.
Unreachable,
/// An http:// probe whose connection was closed without an answer. A
/// Codeman started with `--https` does exactly that to plain HTTP.
MaybeHttps,
Timeout,
TlsUntrusted {
detail: String,
},
Unresolvable,
Failed {
detail: String,
},
}
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
/// was asked, `location` and `retry_after` are those response headers.
pub fn classify_response(
request: &Url,
status: u16,
location: Option<&str>,
retry_after: Option<&str>,
sent_credentials: bool,
body: &str,
) -> ProbeOutcome {
match status {
200 => {
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let version = parsed.as_ref().and_then(|v| {
(v.get("success")? == &serde_json::Value::Bool(true))
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
.flatten()
});
match version {
Some(version) => ProbeOutcome::Ok { version },
None => ProbeOutcome::NotCodeman { status },
}
}
300..=399 => {
// A 3xx without a usable Location (304, a garbled header) is not a
// redirect anyone can act on.
match location.and_then(|l| request.join(l.trim()).ok()) {
Some(target) => ProbeOutcome::Redirect {
suggested: suggested_server(request, &target),
location: target.into(),
},
None => ProbeOutcome::NotCodeman { status },
}
}
401 if sent_credentials => ProbeOutcome::BadCredentials,
401 => ProbeOutcome::NeedsLogin,
403 if body.contains("host not allowed") => ProbeOutcome::HostNotAllowed,
// Codeman only says this after the Basic credentials verified (the
// probe sends no cookie), so without credentials it is not Codeman's
// answer to us, and loading the page without them would not help.
403 if sent_credentials && error_code(body).as_deref() == Some("PASSWORD_CHANGE_REQUIRED") => {
ProbeOutcome::PasswordChangeRequired
}
429 => ProbeOutcome::RateLimited {
sent_credentials,
retry_after_secs: retry_after.and_then(|v| v.trim().parse::<u64>().ok()),
},
other => ProbeOutcome::NotCodeman { status: other },
}
}
/// `errorCode` of a Codeman `ApiResponse` error body.
fn error_code(body: &str) -> Option<String> {
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
Some(parsed.get("errorCode")?.as_str()?.to_string())
}
/// The server base URL a redirect points to, when it is the same server
/// moved to https or another port (a reverse proxy upgrading plain HTTP, the
/// usual case). Anything else, such as another host (a login gateway the
/// window could not pass anyway) or a downgrade to http, gets no suggestion.
fn suggested_server(request: &Url, target: &Url) -> Option<String> {
let upgrade = request.scheme() == "http" && target.scheme() == "https";
let port_change =
request.scheme() == target.scheme() && request.port_or_known_default() != target.port_or_known_default();
if !(upgrade || port_change) || target.host() != request.host() {
return None;
}
if !target.username().is_empty() || target.password().is_some() {
return None;
}
// The redirect must keep pointing at the status API (a proxy may add a
// base path in front of it); the base is what comes before it.
if !target.path().ends_with(request.path()) || !request.path().ends_with(STATUS_PATH) {
return None;
}
let base_path = &target.path()[..target.path().len() - STATUS_PATH.len()];
let mut base = target.clone();
base.set_path(base_path);
base.set_query(None);
base.set_fragment(None);
Some(base.into())
}
/// Transport failures, reduced to what the connect page explains differently.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportError {
Refused,
Unreachable,
ClosedWithoutAnswer,
Timeout,
Tls(String),
Dns,
Other(String),
}
pub fn classify_transport(err: TransportError) -> ProbeOutcome {
match err {
TransportError::Refused => ProbeOutcome::Refused,
TransportError::Unreachable => ProbeOutcome::Unreachable,
TransportError::ClosedWithoutAnswer => ProbeOutcome::MaybeHttps,
TransportError::Timeout => ProbeOutcome::Timeout,
TransportError::Tls(detail) => ProbeOutcome::TlsUntrusted { detail },
TransportError::Dns => ProbeOutcome::Unresolvable,
TransportError::Other(detail) => ProbeOutcome::Failed { detail },
}
}
/// `plain_http`: the probe went to an http:// URL. Only there does a closed
/// connection hint at an https-only server; over https the same close is a
/// failed handshake with nothing more to say about it.
fn transport_error(err: &ureq::Error, plain_http: bool) -> TransportError {
use std::io::ErrorKind;
match err {
ureq::Error::Timeout(_) => TransportError::Timeout,
ureq::Error::HostNotFound => TransportError::Dns,
ureq::Error::ConnectionFailed => TransportError::Refused,
// ureq passes both a rejected certificate (rustls wraps it as
// InvalidData during the handshake) and a failed name lookup (std's
// own error) through as plain io errors, so they are told apart here
// before the kinds below swallow them.
ureq::Error::Io(e) if e.kind() == ErrorKind::InvalidData && e.to_string().starts_with(CERT_REJECTED_PREFIX) => {
TransportError::Tls(e.to_string())
}
ureq::Error::Io(e) if e.raw_os_error().is_none() && e.to_string().starts_with(LOOKUP_FAILED_PREFIX) => {
TransportError::Dns
}
ureq::Error::Io(e) => match e.kind() {
ErrorKind::ConnectionRefused | ErrorKind::ConnectionReset | ErrorKind::AddrNotAvailable => {
TransportError::Refused
}
ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => TransportError::Unreachable,
ErrorKind::UnexpectedEof if plain_http => TransportError::ClosedWithoutAnswer,
ErrorKind::TimedOut | ErrorKind::WouldBlock => TransportError::Timeout,
_ => TransportError::Other(e.to_string()),
},
// ureq's own rustls errors come from setting up the TLS client (the
// handshake's arrive as Io above); only a certificate rejection is
// "untrusted", anything else is reported as it is.
ureq::Error::Rustls(e) if e.to_string().starts_with(CERT_REJECTED_PREFIX) => TransportError::Tls(e.to_string()),
other => TransportError::Other(other.to_string()),
}
}
/// Ask the server. Blocking: call it from a worker thread.
pub fn probe(server: &Url, authorization: Option<&str>) -> ProbeOutcome {
let status_url = match server.join(STATUS_PATH) {
Ok(u) => u,
Err(e) => return ProbeOutcome::Failed { detail: e.to_string() },
};
let tls = ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::PlatformVerifier)
.build();
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(TIMEOUT))
.http_status_as_error(false)
// Never follow: a 3xx comes back as an answer and is explained.
.max_redirects(0)
.tls_config(tls)
.user_agent(concat!("CodemanDesktop/", env!("CARGO_PKG_VERSION")))
.build()
.into();
let mut req = agent.get(status_url.as_str()).header("Accept", "application/json");
if let Some(auth) = authorization {
req = req.header("Authorization", auth);
}
match req.call() {
Ok(mut resp) => {
let status = resp.status().as_u16();
let header = |name: &str| {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let location = header("location");
let retry_after = header("retry-after");
let body = resp
.body_mut()
.with_config()
.limit(MAX_BODY)
.read_to_string()
.unwrap_or_default();
classify_response(
&status_url,
status,
location.as_deref(),
retry_after.as_deref(),
authorization.is_some(),
&body,
)
}
Err(e) => classify_transport(transport_error(&e, status_url.scheme() == "http")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
fn status_url() -> Url {
Url::parse("http://studio.lan:3000/api/status").unwrap()
}
/// `classify_response` for the cases that need no headers.
fn classify(status: u16, sent_credentials: bool, body: &str) -> ProbeOutcome {
classify_response(&status_url(), status, None, None, sent_credentials, body)
}
fn redirect(request: &str, location: &str) -> ProbeOutcome {
classify_response(&Url::parse(request).unwrap(), 301, Some(location), None, false, "")
}
fn io_outcome(kind: io::ErrorKind, message: &str, plain_http: bool) -> ProbeOutcome {
let err = ureq::Error::Io(io::Error::new(kind, message.to_string()));
classify_transport(transport_error(&err, plain_http))
}
#[test]
fn ok_needs_success_and_version() {
assert_eq!(
classify(
200,
false,
r#"{"success":true,"data":{"version":"1.47.0","sessions":[]}}"#
),
ProbeOutcome::Ok {
version: "1.47.0".into()
}
);
assert_eq!(
classify(200, false, "<html>hello</html>"),
ProbeOutcome::NotCodeman { status: 200 }
);
assert_eq!(
classify(200, false, r#"{"success":false,"data":{"version":"1"}}"#),
ProbeOutcome::NotCodeman { status: 200 }
);
assert_eq!(
classify(200, false, r#"{"success":true}"#),
ProbeOutcome::NotCodeman { status: 200 }
);
}
#[test]
fn auth_and_guards() {
assert_eq!(classify(401, false, "Unauthorized"), ProbeOutcome::NeedsLogin);
assert_eq!(classify(401, true, "Unauthorized"), ProbeOutcome::BadCredentials);
assert_eq!(
classify(403, false, "Forbidden: host not allowed"),
ProbeOutcome::HostNotAllowed
);
// The Host guard runs before authentication, so it wins even with credentials.
assert_eq!(
classify(403, true, "Forbidden: host not allowed"),
ProbeOutcome::HostNotAllowed
);
assert_eq!(classify(403, false, "nope"), ProbeOutcome::NotCodeman { status: 403 });
assert_eq!(classify(502, false, ""), ProbeOutcome::NotCodeman { status: 502 });
}
/// The exact body auth.ts sends: `createErrorResponse(PASSWORD_CHANGE_REQUIRED)`.
#[test]
fn password_change_required_only_after_sent_credentials() {
let body = r#"{"success":false,"error":"You must change your password before continuing","errorCode":"PASSWORD_CHANGE_REQUIRED"}"#;
assert_eq!(classify(403, true, body), ProbeOutcome::PasswordChangeRequired);
assert_eq!(classify(403, false, body), ProbeOutcome::NotCodeman { status: 403 });
// Another Codeman error code, or the text without the JSON field, is not it.
assert_eq!(
classify(403, true, r#"{"success":false,"errorCode":"FORBIDDEN"}"#),
ProbeOutcome::NotCodeman { status: 403 }
);
assert_eq!(
classify(403, true, "PASSWORD_CHANGE_REQUIRED"),
ProbeOutcome::NotCodeman { status: 403 }
);
}
#[test]
fn rate_limited_says_whether_credentials_went_along() {
let body = "Too Many Requests, try again later";
assert_eq!(
classify(429, false, body),
ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: None
}
);
assert_eq!(
classify(429, true, body),
ProbeOutcome::RateLimited {
sent_credentials: true,
retry_after_secs: None
}
);
// auth.ts sends Retry-After in whole seconds; an HTTP date is not parsed.
assert_eq!(
classify_response(&status_url(), 429, None, Some(" 742 "), true, body),
ProbeOutcome::RateLimited {
sent_credentials: true,
retry_after_secs: Some(742)
}
);
assert_eq!(
classify_response(
&status_url(),
429,
None,
Some("Wed, 21 Oct 2026 07:28:00 GMT"),
false,
body
),
ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: None
}
);
}
#[test]
fn redirect_to_https_on_the_same_host_is_suggested() {
assert_eq!(
redirect("http://studio.lan/api/status", "https://studio.lan/api/status"),
ProbeOutcome::Redirect {
location: "https://studio.lan/api/status".into(),
suggested: Some("https://studio.lan/".into())
}
);
// A base path the proxy adds stays part of the suggestion; a query does not.
assert_eq!(
redirect(
"http://studio.lan:3000/api/status",
"https://studio.lan/codeman/api/status?from=http"
),
ProbeOutcome::Redirect {
location: "https://studio.lan/codeman/api/status?from=http".into(),
suggested: Some("https://studio.lan/codeman/".into())
}
);
// A relative Location resolves against the probed URL; another port is fine.
assert_eq!(
redirect(
"http://studio.lan:3000/base/api/status",
"//studio.lan:3001/base/api/status"
),
ProbeOutcome::Redirect {
location: "http://studio.lan:3001/base/api/status".into(),
suggested: Some("http://studio.lan:3001/base/".into())
}
);
}
#[test]
fn other_redirects_are_reported_without_a_suggestion() {
// Another host (a login gateway, say).
assert_eq!(
redirect("http://studio.lan/api/status", "https://sso.example.com/login?next=x"),
ProbeOutcome::Redirect {
location: "https://sso.example.com/login?next=x".into(),
suggested: None
}
);
// Same host, but no longer the status API.
assert_eq!(
redirect("http://studio.lan/api/status", "/login"),
ProbeOutcome::Redirect {
location: "http://studio.lan/login".into(),
suggested: None
}
);
// A downgrade to plain http.
assert_eq!(
redirect("https://studio.lan/api/status", "http://studio.lan/api/status"),
ProbeOutcome::Redirect {
location: "http://studio.lan/api/status".into(),
suggested: None
}
);
// Same scheme and port, only the path moved.
assert_eq!(
redirect("http://studio.lan/api/status", "/x/api/status"),
ProbeOutcome::Redirect {
location: "http://studio.lan/x/api/status".into(),
suggested: None
}
);
// Credentials in the target are never suggested.
assert_eq!(
redirect("http://studio.lan/api/status", "https://u:p@studio.lan/api/status"),
ProbeOutcome::Redirect {
location: "https://u:p@studio.lan/api/status".into(),
suggested: None
}
);
// No Location (304), or one that does not parse: nothing to act on.
assert_eq!(
classify_response(&status_url(), 304, None, None, false, ""),
ProbeOutcome::NotCodeman { status: 304 }
);
assert_eq!(
classify_response(&status_url(), 302, Some("http://[::1"), None, false, ""),
ProbeOutcome::NotCodeman { status: 302 }
);
}
#[test]
fn transport_classes() {
assert_eq!(classify_transport(TransportError::Refused), ProbeOutcome::Refused);
assert_eq!(classify_transport(TransportError::Timeout), ProbeOutcome::Timeout);
assert_eq!(classify_transport(TransportError::Dns), ProbeOutcome::Unresolvable);
assert_eq!(
classify_transport(TransportError::Tls("bad cert".into())),
ProbeOutcome::TlsUntrusted {
detail: "bad cert".into()
}
);
}
/// The io errors exactly as ureq hands them over (`Error::Io`).
#[test]
fn io_errors_are_told_apart() {
use io::ErrorKind;
// rustls' certificate rejection from the handshake.
let cert = "invalid peer certificate: UnknownIssuer";
assert_eq!(
io_outcome(ErrorKind::InvalidData, cert, false),
ProbeOutcome::TlsUntrusted { detail: cert.into() }
);
// Any other InvalidData (a port that does not speak TLS, say) is not a certificate problem.
assert_eq!(
io_outcome(
ErrorKind::InvalidData,
"received corrupt message of type InvalidContentType",
false
),
ProbeOutcome::Failed {
detail: "received corrupt message of type InvalidContentType".into()
}
);
// std's getaddrinfo failure (its real kind, Uncategorized, cannot be built on stable).
assert_eq!(
io_outcome(
ErrorKind::Other,
"failed to lookup address information: nodename nor servname provided, or not known",
true
),
ProbeOutcome::Unresolvable
);
// The same text from the OS (an errno) is not a lookup failure.
let os = ureq::Error::Io(io::Error::from_raw_os_error(1));
assert!(!matches!(
classify_transport(transport_error(&os, true)),
ProbeOutcome::Unresolvable
));
// ureq's "Peer disconnected": over http a hint at https, over https just a failure.
assert_eq!(
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", true),
ProbeOutcome::MaybeHttps
);
assert_eq!(
io_outcome(ErrorKind::UnexpectedEof, "Peer disconnected", false),
ProbeOutcome::Failed {
detail: "Peer disconnected".into()
}
);
// No route: Local Network privacy, or a machine that is off.
assert_eq!(
io_outcome(ErrorKind::HostUnreachable, "No route to host", true),
ProbeOutcome::Unreachable
);
assert_eq!(
io_outcome(ErrorKind::NetworkUnreachable, "Network is unreachable", false),
ProbeOutcome::Unreachable
);
assert_eq!(
io_outcome(ErrorKind::ConnectionRefused, "Connection refused", true),
ProbeOutcome::Refused
);
assert_eq!(
classify_transport(transport_error(&ureq::Error::HostNotFound, true)),
ProbeOutcome::Unresolvable
);
// A ureq TLS setup message is not an untrusted certificate.
assert_eq!(
classify_transport(transport_error(
&ureq::Error::Tls("Rustls invalid dns name error"),
false
)),
ProbeOutcome::Failed {
detail: "Rustls invalid dns name error".into()
}
);
}
#[test]
fn outcome_serializes_for_the_page() {
let json = |o: ProbeOutcome| serde_json::to_value(o).unwrap();
assert_eq!(
json(ProbeOutcome::NotCodeman { status: 404 }),
serde_json::json!({"kind":"notCodeman","status":404})
);
assert_eq!(json(ProbeOutcome::NeedsLogin), serde_json::json!({"kind":"needsLogin"}));
assert_eq!(
json(ProbeOutcome::RateLimited {
sent_credentials: true,
retry_after_secs: None
}),
serde_json::json!({"kind":"rateLimited","sentCredentials":true})
);
assert_eq!(
json(ProbeOutcome::RateLimited {
sent_credentials: false,
retry_after_secs: Some(60)
}),
serde_json::json!({"kind":"rateLimited","sentCredentials":false,"retryAfterSecs":60})
);
assert_eq!(
json(ProbeOutcome::PasswordChangeRequired),
serde_json::json!({"kind":"passwordChangeRequired"})
);
assert_eq!(
json(ProbeOutcome::Redirect {
location: "https://studio.lan/api/status".into(),
suggested: Some("https://studio.lan/".into())
}),
serde_json::json!({"kind":"redirect","location":"https://studio.lan/api/status","suggested":"https://studio.lan/"})
);
assert_eq!(
json(ProbeOutcome::Redirect {
location: "https://sso.example.com/".into(),
suggested: None
}),
serde_json::json!({"kind":"redirect","location":"https://sso.example.com/","suggested":null})
);
assert_eq!(
json(ProbeOutcome::Unreachable),
serde_json::json!({"kind":"unreachable"})
);
assert_eq!(json(ProbeOutcome::MaybeHttps), serde_json::json!({"kind":"maybeHttps"}));
assert_eq!(
json(ProbeOutcome::TlsUntrusted { detail: "x".into() }),
serde_json::json!({"kind":"tlsUntrusted","detail":"x"})
);
assert_eq!(
json(ProbeOutcome::Unresolvable),
serde_json::json!({"kind":"unresolvable"})
);
}
/// Against real sockets: a closed port is `Refused`, a listener that
/// speaks HTTP but not Codeman is `NotCodeman`, a fake Codeman is `Ok`,
/// a 401 is `NeedsLogin`, a redirect is reported without being followed,
/// and a listener that hangs up without answering is `MaybeHttps`.
#[test]
fn probe_against_local_sockets() {
use std::io::{Read, Write};
use std::net::TcpListener;
let closed = {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let url = Url::parse(&format!("http://127.0.0.1:{closed}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::Refused);
/// Accept one connection, read the request and send `response`
/// (nothing, then close, for an empty one).
fn serve_raw(response: String) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut s, _)) = l.accept() {
// Read the whole request head (a GET has no body) so that
// closing sends a FIN, not a reset for unread bytes.
let mut head = Vec::new();
let mut buf = [0u8; 4096];
while !head.windows(4).any(|w| w == b"\r\n\r\n") {
match s.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => head.extend_from_slice(&buf[..n]),
}
}
let _ = s.write_all(response.as_bytes());
}
});
port
}
fn serve_once(status_line: &str, extra_headers: &str, body: &str) -> u16 {
serve_raw(format!(
"HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra_headers}Connection: close\r\n\r\n{body}",
body.len()
))
}
let p = serve_once("200 OK", "", r#"{"success":true,"data":{"version":"9.9.9"}}"#);
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(
probe(&url, None),
ProbeOutcome::Ok {
version: "9.9.9".into()
}
);
let p = serve_once("404 Not Found", "", "nope");
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::NotCodeman { status: 404 });
let p = serve_once("401 Unauthorized", "", "Unauthorized");
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::NeedsLogin);
let p = serve_once("429 Too Many Requests", "Retry-After: 30\r\n", "Too Many Requests");
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(
probe(&url, Some("Basic YWRtaW46eA==")),
ProbeOutcome::RateLimited {
sent_credentials: true,
retry_after_secs: Some(30)
}
);
// Nothing listens on the redirect target: had it been followed, the
// answer would be Refused instead.
let p = serve_once(
"301 Moved Permanently",
&format!("Location: https://127.0.0.1:{closed}/api/status\r\n"),
"",
);
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(
probe(&url, None),
ProbeOutcome::Redirect {
location: format!("https://127.0.0.1:{closed}/api/status"),
suggested: Some(format!("https://127.0.0.1:{closed}/"))
}
);
let p = serve_raw(String::new());
let url = Url::parse(&format!("http://127.0.0.1:{p}/")).unwrap();
assert_eq!(probe(&url, None), ProbeOutcome::MaybeHttps);
}
}