mod actions;
pub mod chat;
#[cfg(feature = "debug")]
mod debug;
mod http;
mod report;
pub mod tunnel;
use crate::cli;
use crate::fleet;
use crate::loader::Loader;
use crate::pricing::Plan;
use crate::session::Session;
use crate::watch::Watch;
use http::{EventStream, Request};
use std::collections::HashMap;
use std::io::Write;
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
const DEFAULT_PORT: u16 = 7777;
const PORT_SEARCH: u16 = 16;
const MAX_CONNECTIONS: usize = 32;
const FULL_WALK: Duration = Duration::from_secs(30);
const SSE_KEEPALIVE: Duration = Duration::from_secs(20);
const NO_SUCH_SESSION: &str = "no session with that id, or the prefix matches more than one";
const DASHBOARD_HTML: &str = include_str!("assets/dashboard.html");
const REPORT_HTML: &str = include_str!("assets/report.html");
const COMMON_CSS: &str = include_str!("assets/common.css");
struct Shared {
token: String,
actions: bool,
plan: Plan,
latest: Mutex<Arc<Snapshot>>,
updated: Condvar,
store: crate::cache::Store,
}
struct Snapshot {
version: u64,
json: String,
sessions: Vec<Session>,
host_errors: Vec<(String, String)>,
}
#[derive(Default)]
struct Remotes {
rows: HashMap<String, Vec<Session>>,
errors: HashMap<String, String>,
}
pub const HELP: &str = "\
cctop serve — the session table, and the sessions themselves, in a browser
USAGE:
cctop serve [OPTIONS]
OPTIONS:
--bind <ADDR> Address to listen on [default: 127.0.0.1]. Anything other
than a loopback address puts the page on your network, which
is announced on stderr when it happens
--port <PORT> Port to listen on [default: 7777]. Without this flag a busy
port is stepped past; with it, a busy port is an error
--no-token Serve without an access token. Every process and user on the
machine can then read your sessions — and since the token is
what authorises an action, this also turns actions off
--no-actions Serve the pages without the buttons: no prompts, no resuming,
no handing a session to another agent
--tunnel Also reach the page from anywhere, over a trycloudflare quick
tunnel. Needs nothing installed, lasts as long as this
process, and puts the link on the public internet — so the
token is what stands between it and your agents
--plan <PLAN> Billing plan for cost figures: retail, max, or included
[default: retail]
--delay <SECS> Seconds between refreshes [default: 2]
--host <HOST> Also serve the sessions on another machine, over ssh.
Repeatable; same syntax as `cctop --host`
-h, --help Print this help
The page shows each session's conversation, what it edited, and what it can
reach, and it can send a prompt to a live session, resume a dead one, or hand
one to a different agent. Whoever holds the link can do all of that, which is
why the link carries a token and the default is loopback only.
";
pub struct Options {
pub bind: String,
pub port: u16,
pub port_given: bool,
pub no_token: bool,
pub no_actions: bool,
pub tunnel: bool,
pub plan: Plan,
pub delay: Duration,
pub hosts: Vec<String>,
pub scan: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
bind: "127.0.0.1".to_string(),
port: DEFAULT_PORT,
port_given: false,
no_token: false,
no_actions: false,
tunnel: false,
plan: Plan::Retail,
delay: Duration::from_secs(2),
hosts: Vec::new(),
scan: true,
}
}
}
pub struct Serving {
pub local: String,
pub public: Option<String>,
pub actions: bool,
shared: Arc<Shared>,
remotes: Arc<Mutex<Remotes>>,
plan: Plan,
version: Mutex<u64>,
_tunnel: Option<tunnel::Tunnel>,
running: Arc<AtomicBool>,
port: u16,
}
impl Serving {
pub fn best(&self) -> &str {
self.public.as_deref().unwrap_or(&self.local)
}
pub fn publish(&self, sessions: &[Session]) {
let Ok(mut version) = self.version.lock() else {
return;
};
publish(
&self.shared,
&self.remotes,
sessions,
self.plan,
&self.shared.store,
&mut version,
);
}
}
pub fn open_in_browser(url: &str) -> bool {
std::process::Command::new("xdg-open")
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok()
}
impl Drop for Serving {
fn drop(&mut self) {
self.running.store(false, Ordering::Relaxed);
let _ = std::net::TcpStream::connect(("127.0.0.1", self.port));
}
}
pub fn start(options: Options) -> anyhow::Result<Serving> {
let listener = listen(&options.bind, options.port, options.port_given)?;
let addr = listener.local_addr()?;
if let Some(why) = tunnel_objection(options.tunnel, addr.ip().is_loopback(), options.no_token) {
anyhow::bail!("{why}");
}
let token = match options.no_token {
true => String::new(),
false => new_token(),
};
let actions = !options.no_actions && !options.no_token;
let tunnel = match options.tunnel {
true => Some(tunnel::start(addr.port())?),
false => None,
};
let shared = Arc::new(Shared {
token: token.clone(),
actions,
plan: options.plan,
latest: Mutex::new(Arc::new(Snapshot {
version: 0,
json: "[]".to_string(),
sessions: Vec::new(),
host_errors: Vec::new(),
})),
updated: Condvar::new(),
store: crate::cache::Store::new(),
});
let remotes = Arc::new(Mutex::new(Remotes::default()));
for host in fleet::Host::collect(&options.hosts) {
spawn_host_poller(host, Arc::clone(&remotes));
}
if options.scan {
spawn_refresher(
Arc::clone(&shared),
Arc::clone(&remotes),
options.plan,
options.delay,
);
}
let query = match token.is_empty() {
true => String::new(),
false => format!("?t={token}"),
};
let running = Arc::new(AtomicBool::new(true));
{
let (shared, running) = (Arc::clone(&shared), Arc::clone(&running));
std::thread::Builder::new()
.name("cctop-serve-accept".into())
.spawn(move || accept_loop(listener, shared, running))?;
}
Ok(Serving {
local: format!("http://127.0.0.1:{}/{query}", addr.port()),
public: tunnel.as_ref().map(|t| format!("{}/{query}", t.url)),
actions,
shared,
remotes,
plan: options.plan,
version: Mutex::new(0),
_tunnel: tunnel,
running,
port: addr.port(),
})
}
fn accept_loop(listener: TcpListener, shared: Arc<Shared>, running: Arc<AtomicBool>) {
let live = Arc::new(AtomicUsize::new(0));
for stream in listener.incoming() {
if !running.load(Ordering::Relaxed) {
return;
}
let Ok(mut stream) = stream else { continue };
if live.load(Ordering::Relaxed) >= MAX_CONNECTIONS {
http::respond_error(&mut stream, None, 503, "too many open connections");
continue;
}
let slot = Connection::take(&live);
let shared = Arc::clone(&shared);
let _ = std::thread::Builder::new()
.name("cctop-serve".into())
.spawn(move || {
let _slot = slot;
serve_connection(&shared, &mut stream);
});
}
}
pub fn run(argv: &[String]) -> anyhow::Result<i32> {
let mut bind = "127.0.0.1".to_string();
let mut port = DEFAULT_PORT;
let mut port_given = false;
let mut no_token = false;
let mut no_actions = false;
let mut want_tunnel = false;
let mut plan = Plan::Retail;
let mut delay = Duration::from_secs(2);
let mut hosts: Vec<String> = Vec::new();
let mut it = argv.iter();
while let Some(flag) = it.next() {
let mut value = || {
it.next()
.cloned()
.ok_or_else(|| anyhow::anyhow!("{flag} needs a value"))
};
match flag.as_str() {
"-h" | "--help" => {
print!("{HELP}");
return Ok(0);
}
"--bind" => bind = value()?,
"--port" => {
port = value()?
.parse()
.map_err(|_| anyhow::anyhow!("--port takes a number from 1 to 65535"))?;
port_given = true;
}
"--no-token" => no_token = true,
"--no-actions" => no_actions = true,
"--tunnel" => want_tunnel = true,
"--plan" => {
let given = value()?;
plan = Plan::parse(&given).ok_or_else(|| {
anyhow::anyhow!("unsupported plan '{given}'; use retail, max or included")
})?;
}
"--delay" => {
let secs: f64 = value()?
.parse()
.map_err(|_| anyhow::anyhow!("--delay takes a number of seconds"))?;
if !secs.is_finite() || !(0.5..3600.0).contains(&secs) {
anyhow::bail!("--delay must be between 0.5 and 3600 seconds");
}
delay = Duration::from_secs_f64(secs);
}
"--host" => hosts.push(value()?),
other => anyhow::bail!("unknown option '{other}'\n\n{HELP}"),
}
}
if want_tunnel {
eprintln!("cctop: opening a trycloudflare tunnel…");
let _ = std::io::stderr().flush();
}
let serving = start(Options {
bind: bind.clone(),
port,
port_given,
no_token,
no_actions,
tunnel: want_tunnel,
plan,
delay,
hosts,
scan: true,
})?;
announce(&serving, &bind, no_token);
loop {
std::thread::park();
}
}
struct Connection(Arc<AtomicUsize>);
impl Connection {
fn take(live: &Arc<AtomicUsize>) -> Connection {
live.fetch_add(1, Ordering::Relaxed);
Connection(Arc::clone(live))
}
}
impl Drop for Connection {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::Relaxed);
}
}
fn listen(bind: &str, port: u16, port_given: bool) -> anyhow::Result<TcpListener> {
let last = match port_given {
true => port,
false => port.saturating_add(PORT_SEARCH),
};
let mut last_error = None;
for candidate in port..=last {
let addr = (bind, candidate)
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("could not resolve --bind {bind}: {e}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("--bind {bind} resolved to no address"))?;
match TcpListener::bind(addr) {
Ok(listener) => return Ok(listener),
Err(e) => last_error = Some(e),
}
}
let why = last_error.map_or_else(|| "no port to try".to_string(), |e| e.to_string());
match port_given {
true => anyhow::bail!("could not listen on {bind}:{port}: {why}"),
false => anyhow::bail!(
"could not listen on {bind}, ports {port} to {last}: {why}\n\
Use --port to name a free one."
),
}
}
fn tunnel_objection(want_tunnel: bool, loopback: bool, no_token: bool) -> Option<&'static str> {
if !want_tunnel {
return None;
}
if no_token {
return Some(
"--tunnel with --no-token would publish your sessions, and a prompt \
box for your agents, to anyone who finds the URL.\n\
Drop one of the two: the token is what makes the link a credential.",
);
}
if !loopback {
return Some(
"--tunnel does not need --bind: the tunnel connects out from this \
machine, so it reaches a loopback listener.\n\
Binding wider would put the page on your local network as well, \
which --tunnel is not asking for.",
);
}
None
}
fn announce(serving: &Serving, bind: &str, no_token: bool) {
if let Some(public) = &serving.public {
eprintln!("cctop: serving on {public}");
eprintln!("cctop: also on {}", serving.local);
eprintln!(
"cctop: that first link is on the public internet. Anyone who has it \
can read every session on this machine — and, unless --no-actions, \
type at your agents, which runs commands as you. Cloudflare carries \
the traffic and can read it. The tunnel ends when this process does."
);
let _ = std::io::stderr().flush();
} else {
eprintln!("cctop: serving on {}", serving.local);
}
if bind != "127.0.0.1" && serving.public.is_none() {
eprintln!(
"cctop: bound to {bind}, so this is on your network and not just this \
machine"
);
}
if no_token {
eprintln!(
"cctop: no token — every process and user on this machine can read \
your sessions"
);
}
let _ = std::io::stderr().flush();
}
fn new_token() -> String {
crate::util::random_hex(TOKEN_BYTES)
}
const TOKEN_BYTES: usize = 16;
fn token_matches(expected: &str, given: &str) -> bool {
if expected.len() != given.len() {
return false;
}
let mut diff = 0u8;
for (a, b) in expected.bytes().zip(given.bytes()) {
diff |= a ^ b;
}
diff == 0
}
fn spawn_host_poller(host: fleet::Host, remotes: Arc<Mutex<Remotes>>) {
std::thread::spawn(move || {
loop {
let snapshot = host.poll();
if let Ok(mut remotes) = remotes.lock() {
match snapshot {
fleet::Snapshot::Rows(rows) => {
remotes.errors.remove(&host.target);
remotes.rows.insert(host.target.clone(), rows);
}
fleet::Snapshot::Failed(why) => {
remotes.errors.insert(host.target.clone(), why);
}
}
}
std::thread::sleep(fleet::POLL);
}
});
}
fn spawn_refresher(shared: Arc<Shared>, remotes: Arc<Mutex<Remotes>>, plan: Plan, delay: Duration) {
std::thread::spawn(move || {
crate::pricing::refresh_pricing_blocking();
let mut loader = Loader::new();
let watch = Watch::start();
let mut rows = loader.load(plan);
let mut walked = Instant::now();
let mut version = 0u64;
publish(&shared, &remotes, &rows, plan, loader.store(), &mut version);
loop {
std::thread::sleep(delay);
let appeared = watch.as_ref().is_some_and(|w| {
w.took_structural_change()
|| w.awaiting_discovery(|path| {
rows.iter().any(|s| s.data_file.as_deref() == Some(path))
})
});
if appeared || walked.elapsed() >= FULL_WALK {
rows = loader.load(plan);
walked = Instant::now();
} else {
loader.refresh_live(plan, &mut rows);
}
publish(&shared, &remotes, &rows, plan, loader.store(), &mut version);
}
});
}
fn publish(
shared: &Shared,
remotes: &Mutex<Remotes>,
local: &[Session],
plan: Plan,
store: &crate::cache::Store,
version: &mut u64,
) {
let (mut sessions, host_errors) = match remotes.lock() {
Ok(remotes) => {
let mut merged = local.to_vec();
for rows in remotes.rows.values() {
merged.extend(rows.iter().cloned());
}
let mut errors: Vec<(String, String)> = remotes
.errors
.iter()
.map(|(h, why)| (h.clone(), why.clone()))
.collect();
errors.sort();
(merged, errors)
}
Err(_) => (local.to_vec(), Vec::new()),
};
sessions.sort_by(|a, b| b.last_active.cmp(&a.last_active));
let document = cli::json_sessions(&sessions, plan, store);
let json = serde_json::to_string(&document).unwrap_or_else(|_| "[]".to_string());
*version += 1;
let snapshot = Arc::new(Snapshot {
version: *version,
json,
sessions,
host_errors,
});
if let Ok(mut latest) = shared.latest.lock() {
*latest = snapshot;
}
shared.updated.notify_all();
}
fn serve_connection(shared: &Shared, stream: &mut TcpStream) {
let request = match Request::parse(stream) {
Ok(request) => request,
Err((status, why)) => return http::respond_error(stream, None, status, why),
};
if !shared.token.is_empty() && !token_matches(&shared.token, request.token()) {
return http::respond_error(
stream,
Some(&request),
403,
"missing or wrong access token — open the link cctop printed",
);
}
#[cfg(feature = "debug")]
if debug::intercept(stream, &request) {
return;
}
let path = request.path.clone();
#[cfg(feature = "debug")]
if let Some(rest) = path.strip_prefix("/api/debug/")
&& debug::route(shared, stream, &request, rest)
{
return;
}
match path.as_str() {
"/" => page(shared, stream, &request, DASHBOARD_HTML),
"/api/sessions" => {
let snapshot = current(shared);
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
snapshot.json.as_bytes(),
);
}
"/api/hosts" => {
let snapshot = current(shared);
let body = serde_json::to_string(&snapshot.host_errors).unwrap_or_default();
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
);
}
"/api/events" => events(shared, stream, &request),
"/api/agents" => {
let body = serde_json::json!({
"actions": shared.actions,
"agents": actions::agents(),
});
http::respond(
stream,
Some(&request),
200,
"application/json; charset=utf-8",
body.to_string().as_bytes(),
);
}
_ if path.starts_with("/session/") => page(shared, stream, &request, REPORT_HTML),
_ if path.starts_with("/api/report/") => {
api_report(shared, stream, &request, &path["/api/report/".len()..]);
}
_ if path.starts_with("/api/chat/") => {
api_chat(shared, stream, &request, &path["/api/chat/".len()..]);
}
_ if path.starts_with("/api/access/") => {
api_access(shared, stream, &request, &path["/api/access/".len()..]);
}
_ if path.starts_with("/api/act/") => {
api_act(shared, stream, &request, &path["/api/act/".len()..]);
}
_ => http::respond_error(stream, Some(&request), 404, "no such page"),
}
}
fn api_chat(shared: &Shared, stream: &mut TcpStream, request: &Request, id: &str) {
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
json(stream, request, &chat::build(session));
}
fn api_access(shared: &Shared, stream: &mut TcpStream, request: &Request, id: &str) {
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
let data = shared.store.session_data_fresh(session);
json(stream, request, &crate::access::build(session, Some(&data)));
}
fn api_act(shared: &Shared, stream: &mut TcpStream, request: &Request, rest: &str) {
if !shared.actions {
return http::respond_error(
stream,
Some(request),
403,
"this cctop serve is read-only — restart it without --no-actions, \
and with a token, to act on a session",
);
}
if !request.wants_json() {
return http::respond_error(
stream,
Some(request),
405,
"an action is a POST with a JSON body",
);
}
let body = match request.json() {
Ok(body) => body,
Err((status, why)) => return http::respond_error(stream, Some(request), status, why),
};
let Some((verb, id)) = rest.split_once('/') else {
return http::respond_error(stream, Some(request), 404, "no such action");
};
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
let field = |name: &str| {
body.get(name)
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string()
};
if verb == "image" {
return match actions::image(&field("data")) {
Ok(filed) => json(stream, request, &filed),
Err((status, why)) => http::respond_error(stream, Some(request), status, &why),
};
}
if verb == "terminal" {
return match actions::terminal(session) {
Ok(terminal) => json(stream, request, &terminal),
Err((status, why)) => http::respond_error(stream, Some(request), status, &why),
};
}
let outcome = match verb {
"send" => actions::send(session, &field("text")),
"resume" => actions::resume(session),
"handoff" => {
let data = shared.store.session_data_fresh(session);
actions::handoff(session, Some(&data), &field("agent"))
}
_ => return http::respond_error(stream, Some(request), 404, "no such action"),
};
match outcome {
Ok(done) => json(stream, request, &done),
Err((status, why)) => http::respond_error(stream, Some(request), status, &why),
}
}
fn json<T: serde::Serialize>(stream: &mut TcpStream, request: &Request, value: &T) {
match serde_json::to_string(value) {
Ok(body) => http::respond(
stream,
Some(request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
),
Err(e) => http::respond_error(
stream,
Some(request),
503,
&format!("could not render that: {e}"),
),
}
}
fn current(shared: &Shared) -> Arc<Snapshot> {
match shared.latest.lock() {
Ok(latest) => Arc::clone(&latest),
Err(poisoned) => Arc::clone(&poisoned.into_inner()),
}
}
fn page(shared: &Shared, stream: &mut TcpStream, request: &Request, html: &str) {
let token = serde_json::to_string(&shared.token).unwrap_or_else(|_| "\"\"".to_string());
let back = match shared.token.is_empty() {
true => String::new(),
false => format!("?t={}", shared.token),
};
let home = serde_json::to_string(
&dirs::home_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
)
.unwrap_or_else(|_| "\"\"".to_string());
let body = html
.replace("__CCTOP_CSS__", COMMON_CSS)
.replace(
"\"__CCTOP_ACTIONS__\"",
match shared.actions {
true => "true",
false => "false",
},
)
.replace("\"__CCTOP_TOKEN__\"", &token)
.replace("\"__CCTOP_HOME__\"", &home)
.replace("__CCTOP_BACK__", &back)
.replace("__CCTOP_VERSION__", env!("CARGO_PKG_VERSION"));
http::respond(
stream,
Some(request),
200,
"text/html; charset=utf-8",
body.as_bytes(),
);
}
fn events(shared: &Shared, stream: &mut TcpStream, _request: &Request) {
let Ok(mut sse) = EventStream::open(stream) else {
return;
};
let mut sent = 0u64;
loop {
let snapshot = {
let Ok(latest) = shared.latest.lock() else {
return;
};
let (latest, _) = match shared
.updated
.wait_timeout_while(latest, SSE_KEEPALIVE, |s| s.version <= sent)
{
Ok(pair) => pair,
Err(_) => return,
};
Arc::clone(&latest)
};
if snapshot.version <= sent {
if sse.keepalive().is_err() {
return;
}
continue;
}
if sse.send("sessions", &snapshot.json).is_err() {
return;
}
sent = snapshot.version;
}
}
fn api_report(shared: &Shared, stream: &mut TcpStream, request: &Request, id: &str) {
let snapshot = current(shared);
let Some(session) = find(&snapshot.sessions, id) else {
return http::respond_error(stream, Some(request), 404, NO_SUCH_SESSION);
};
if let Some(remote) = &session.remote {
return http::respond_error(
stream,
Some(request),
404,
&format!(
"this session is on {} — run cctop serve there to report on it",
remote.host
),
);
}
let data = shared.store.session_data_fresh(session);
let built = report::build(session, &data, shared.plan);
match serde_json::to_string(&built) {
Ok(body) => http::respond(
stream,
Some(request),
200,
"application/json; charset=utf-8",
body.as_bytes(),
),
Err(e) => http::respond_error(
stream,
Some(request),
503,
&format!("could not render the report: {e}"),
),
}
}
fn find<'a>(sessions: &'a [Session], id: &str) -> Option<&'a Session> {
if id.is_empty() {
return None;
}
if let Some(exact) = sessions.iter().find(|s| s.session_id == id) {
return Some(exact);
}
let mut matches = sessions.iter().filter(|s| s.session_id.starts_with(id));
let first = matches.next()?;
matches.next().is_none().then_some(first)
}
#[cfg(test)]
mod tests {
use super::*;
fn session(id: &str) -> Session {
Session::new(crate::pricing::Provider::Claude, id.into())
}
#[test]
fn a_token_matches_only_itself() {
let token = new_token();
assert!(token_matches(&token, &token));
assert!(!token_matches(&token, ""));
assert!(!token_matches(&token, &token[..token.len() - 1]));
let mut wrong = token.clone();
wrong.pop();
wrong.push(if token.ends_with('a') { 'b' } else { 'a' });
assert!(!token_matches(&token, &wrong));
}
#[test]
fn tokens_differ_between_runs() {
assert_ne!(new_token(), new_token());
assert_eq!(new_token().len(), TOKEN_BYTES * 2);
}
#[test]
fn sessions_resolve_by_id_and_by_unambiguous_prefix() {
let rows = vec![session("abc123"), session("abd999"), session("zz")];
assert_eq!(find(&rows, "abc123").unwrap().session_id, "abc123");
assert_eq!(find(&rows, "abc").unwrap().session_id, "abc123");
assert_eq!(find(&rows, "zz").unwrap().session_id, "zz");
}
#[test]
fn an_ambiguous_prefix_resolves_to_nothing() {
let rows = vec![session("abc123"), session("abd999")];
assert!(find(&rows, "ab").is_none());
assert!(find(&rows, "").is_none());
assert!(find(&rows, "nope").is_none());
}
#[test]
fn a_tunnel_refuses_the_two_combinations_that_undo_it() {
assert!(tunnel_objection(true, true, true).is_some());
assert!(tunnel_objection(true, false, false).is_some());
assert!(tunnel_objection(true, true, false).is_none());
assert!(tunnel_objection(false, false, true).is_none());
}
#[test]
fn the_pages_carry_the_placeholders_the_server_substitutes() {
for html in [DASHBOARD_HTML, REPORT_HTML] {
assert!(html.contains("\"__CCTOP_TOKEN__\""));
assert!(html.contains("__CCTOP_CSS__"));
assert!(html.contains("__CCTOP_VERSION__"));
assert!(html.contains("\"__CCTOP_ACTIONS__\""));
}
assert!(REPORT_HTML.contains("__CCTOP_BACK__"));
assert!(DASHBOARD_HTML.contains("\"__CCTOP_HOME__\""));
assert!(!COMMON_CSS.contains("</style>"));
}
#[test]
fn a_token_is_safe_to_paste_into_a_url_unescaped() {
let token = new_token();
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token {token} would need escaping in a URL"
);
}
#[test]
fn a_named_port_is_not_stepped_past() {
let held = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let taken = held.local_addr().unwrap().port();
assert!(listen("127.0.0.1", taken, true).is_err());
let stepped = listen("127.0.0.1", taken, false).expect("the search finds a free port");
assert_ne!(stepped.local_addr().unwrap().port(), taken);
}
}