Skip to main content

greentic_setup/
setup_tunnel.rs

1use std::path::{Path, PathBuf};
2use std::process::{Child, Command, Stdio};
3use std::time::Duration;
4
5use anyhow::{Context, Result, anyhow};
6use serde_json::{Map as JsonMap, Value};
7use sha2::{Digest, Sha256};
8
9pub struct SetupTunnel {
10    pub mode: String,
11    pub local_base_url: String,
12    pub public_base_url: String,
13    /// `None` when reusing a tunnel recorded by another Greentic process
14    /// (the shared record owns it, not this setup session).
15    child: Option<Child>,
16    /// Cloudflared tunnels deliberately OUTLIVE setup so the runtime they
17    /// were configured against keeps a live public URL (greentic-start adopts
18    /// them via the shared record). ngrok keeps the old kill-on-drop
19    /// semantics until it gets the same shared-record treatment.
20    kill_on_drop: bool,
21}
22
23impl Drop for SetupTunnel {
24    fn drop(&mut self) {
25        if self.kill_on_drop
26            && let Some(child) = self.child.as_mut()
27        {
28            let _ = child.kill();
29            let _ = child.wait();
30        }
31    }
32}
33
34impl SetupTunnel {
35    pub fn is_running(&mut self) -> bool {
36        match self.child.as_mut() {
37            Some(child) => child.try_wait().ok().flatten().is_none(),
38            // Reused shared-record tunnel: not our child. Liveness is
39            // enforced by the URL probes callers already run.
40            None => true,
41        }
42    }
43
44    /// Handle for a tunnel owned elsewhere (the shared record, or tests):
45    /// no child process, never killed on drop.
46    pub(crate) fn detached(mode: &str, local_base_url: &str, public_base_url: &str) -> Self {
47        Self {
48            mode: mode.to_string(),
49            local_base_url: local_base_url.trim_end_matches('/').to_string(),
50            public_base_url: public_base_url.to_string(),
51            child: None,
52            kill_on_drop: false,
53        }
54    }
55}
56
57/// Default Greentic-operated Worker tunnel base (mirrors greentic-start's
58/// `gtunnel::DEFAULT_WORKER_BASE_URL`; the crates don't share a dependency).
59const DEFAULT_GTUNNEL_WORKER_BASE_URL: &str = "https://greentic-webhook-proxy.greentic.workers.dev";
60
61/// Shared tunnel secret baked into the shipped binaries, matching the Worker's
62/// global `TUNNEL_SECRET` — so the managed tunnel needs no operator credentials.
63/// Mirrors greentic-start's `gtunnel::DEFAULT_TUNNEL_SECRET` (the crates don't
64/// share a dependency); the two MUST stay in sync or setup and start would
65/// register the same tunnel id under different secrets.
66const DEFAULT_TUNNEL_SECRET: &str =
67    "d00a7591949699785228c42504afa41ce168f84e4118bb127e47c5cb98e4dd90";
68
69/// Everything the setup binary needs to bring up the Greentic self-hosted tunnel.
70/// Derived by the caller (from tenant/team + env) so setup and start compute the
71/// same tunnel id and share one agent.
72#[derive(Clone, Debug)]
73pub struct GtunnelSetupCtx {
74    pub worker_url: String,
75    /// When set (`GREENTIC_TUNNEL_BASE_DOMAIN`), use subdomain routing
76    /// (`https://<tunnelId>.<base>`) so the WebChat SPA works at the host root.
77    pub base_domain: Option<String>,
78    /// Root-map mode (`GREENTIC_TUNNEL_ROOT_MAP`): the whole Worker host maps to
79    /// this one tunnel at its root (cloudflared-style), so the WebChat SPA works
80    /// on workers.dev with no custom domain. Ignored when `base_domain` is set.
81    pub root_map: bool,
82    pub tunnel_id: String,
83    pub secret: String,
84}
85
86/// Derive a URL-path-safe tunnel id from the TENANT ALONE — `team` is
87/// deliberately ignored.
88///
89/// The id becomes the first path segment of the public URL, and it must equal
90/// the `<tenant>` segment of the WebChat URL space (`/v1/web/webchat/<tenant>/…`)
91/// so the Worker routes the SPA's root-absolute calls to the right tunnel. That
92/// URL space has no team segment, so folding `team` in here produced a
93/// `<tenant>-<team>` id that could never match a webchat URL — and disagreed
94/// with greentic-start, which keys on the tenant alone.
95///
96/// This is only the FALLBACK derivation now: once setup has run it persists the
97/// resolved id to `.greentic/tunnel.json` and greentic-start reads it verbatim
98/// rather than re-deriving (see [`crate::platform_setup::types::TunnelAnswers`]).
99/// Kept in sync with greentic-start's `sanitize_tunnel_id` by convention — the
100/// crates cannot share the rule via `greentic-types`, which is exact-pinned at
101/// `=1.1.2` across this graph (the same reason `cli_args.rs` keeps a local copy
102/// of `DEFAULT_TENANT`).
103pub fn derive_gtunnel_id(tenant: &str, _team: &str) -> String {
104    let base = sanitize_tunnel_id(tenant);
105    match install_clash_suffix(&tunnel_state_root(), &base) {
106        Some(suffix) => format!("{base}-{suffix}"),
107        None => base,
108    }
109}
110
111/// Slug of `tenant`: lowercase alphanumerics and `-`, everything else folded to
112/// `-`, trimmed, empty falling back to `default`. Matches greentic-start's
113/// `sanitize_tunnel_id`.
114fn sanitize_tunnel_id(tenant: &str) -> String {
115    let slug: String = tenant
116        .chars()
117        .map(|c| {
118            if c.is_ascii_alphanumeric() || c == '-' {
119                c.to_ascii_lowercase()
120            } else {
121                '-'
122            }
123        })
124        .collect();
125    let trimmed = slug.trim_matches('-').to_string();
126    if trimmed.is_empty() {
127        "default".to_string()
128    } else {
129        trimmed
130    }
131}
132
133/// 5-hex clash-avoidance suffix for `base`, derived from the per-install seed at
134/// `<root>/instance-seed`.
135///
136/// The managed tunnel is ONE shared Worker, and almost every operator runs the
137/// default tenant — so a bare `<tenant>` id means every install collides on the
138/// same public path. The suffix is what keeps them distinct.
139///
140/// It must be STABLE, because it ends up inside URLs registered with Slack,
141/// Webex and OAuth providers. It must also be the SAME value greentic-start
142/// derives, which is why the seed lives in the tunnel state root both binaries
143/// already share (alongside `secret` and `secrets/<tunnelId>`) and why the hash
144/// is byte-compatible with start's `tenant_clash_suffix_from_seed`:
145/// last 5 hex of `sha256(seed || 0x00 || base)`.
146///
147/// Returns `None` when no seed can be established, in which case the caller uses
148/// the bare id — degraded (collidable) but functional, rather than failing setup.
149fn install_clash_suffix(root: &Path, base: &str) -> Option<String> {
150    let seed = load_or_create_instance_seed(root)?;
151    let mut hasher = Sha256::new();
152    hasher.update(seed.as_bytes());
153    hasher.update([0u8]);
154    hasher.update(base.as_bytes());
155    let hex: String = hasher
156        .finalize()
157        .iter()
158        .map(|byte| format!("{byte:02x}"))
159        .collect();
160    Some(hex[hex.len() - 5..].to_string())
161}
162
163/// Read `<root>/instance-seed`, creating it on first use. Shared on-disk format
164/// with greentic-start, which reads the same file so both derive one suffix.
165fn load_or_create_instance_seed(root: &Path) -> Option<String> {
166    let path = root.join("instance-seed");
167    if let Ok(existing) = std::fs::read_to_string(&path) {
168        let existing = existing.trim().to_string();
169        if existing.len() == 64 && existing.chars().all(|c| c.is_ascii_hexdigit()) {
170            return Some(existing);
171        }
172    }
173    let seed: String = (0..32)
174        .map(|_| format!("{:02x}", rand::random::<u8>()))
175        .collect();
176    if let Some(parent) = path.parent() {
177        std::fs::create_dir_all(parent).ok()?;
178    }
179    std::fs::write(&path, &seed).ok()?;
180    Some(seed)
181}
182
183impl GtunnelSetupCtx {
184    /// Zero-config context: worker URL and tunnel secret both from env/default,
185    /// so the managed tunnel works on a fresh box with no operator input. Secret
186    /// resolution matches greentic-start's, so both binaries present the same
187    /// credential for the same tunnel id.
188    pub fn new(tunnel_id: String) -> Self {
189        let secret = resolve_tunnel_secret(&tunnel_id);
190        let base_domain = std::env::var("GREENTIC_TUNNEL_BASE_DOMAIN")
191            .ok()
192            .map(|s| s.trim().to_string())
193            .filter(|s| !s.is_empty());
194        // Root-map only applies without a base domain (a subdomain already gives
195        // each tunnel its own host root), matching greentic-start's precedence.
196        let root_map = base_domain.is_none() && env_flag("GREENTIC_TUNNEL_ROOT_MAP");
197        Self {
198            worker_url: gtunnel_worker_base_url(),
199            base_domain,
200            root_map,
201            tunnel_id,
202            secret,
203        }
204    }
205}
206
207/// Base URL of the managed-tunnel Worker: `GREENTIC_TUNNEL_WORKER_URL` when set
208/// and non-empty, else [`DEFAULT_GTUNNEL_WORKER_BASE_URL`].
209///
210/// Shared deliberately by [`GtunnelSetupCtx::new`], which BUILDS the public URL,
211/// and [`is_ephemeral_tunnel_url`], which decides whether an existing URL is one
212/// of ours to re-point. Those two must resolve the same host: if the predicate
213/// tested a different host than the builder used, a managed URL would once again
214/// look permanent and never be refreshed — the exact bug this helper prevents.
215fn gtunnel_worker_base_url() -> String {
216    std::env::var("GREENTIC_TUNNEL_WORKER_URL")
217        .ok()
218        .map(|value| value.trim().to_string())
219        .filter(|value| !value.is_empty())
220        .unwrap_or_else(|| DEFAULT_GTUNNEL_WORKER_BASE_URL.to_string())
221}
222
223/// Truthy env flag: `1`, `true`, or `yes` (case-insensitive). Mirrors the same
224/// helper in greentic-start so both binaries read `GREENTIC_TUNNEL_ROOT_MAP`
225/// identically.
226fn env_flag(key: &str) -> bool {
227    std::env::var(key)
228        .map(|v| {
229            let v = v.trim().to_ascii_lowercase();
230            v == "1" || v == "true" || v == "yes"
231        })
232        .unwrap_or(false)
233}
234
235/// `~/.greentic/tunnel` (override: `GREENTIC_TUNNEL_STATE_DIR`).
236fn tunnel_state_root() -> PathBuf {
237    std::env::var_os("GREENTIC_TUNNEL_STATE_DIR")
238        .map(PathBuf::from)
239        .unwrap_or_else(|| {
240            let var = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
241            std::env::var_os(var)
242                .map(PathBuf::from)
243                .unwrap_or_else(std::env::temp_dir)
244                .join(".greentic")
245                .join("tunnel")
246        })
247}
248
249fn read_secret_file(path: PathBuf) -> Option<String> {
250    let s = std::fs::read_to_string(path).ok()?.trim().to_string();
251    (!s.is_empty()).then_some(s)
252}
253
254/// Resolve the tunnel secret: `GREENTIC_TUNNEL_SECRET` env > per-tunnel store
255/// (`<root>/secrets/<id>`) > operator secret (`<root>/secret`) >
256/// [`DEFAULT_TUNNEL_SECRET`]. Never empty — the baked-in constant is the whole
257/// point: the managed tunnel authenticates with no operator credentials at all.
258/// Must stay identical to greentic-start's `gtunnel::resolve_secret`, since
259/// either binary can be the one that spawns the agent for a given tunnel id.
260fn resolve_tunnel_secret(tunnel_id: &str) -> String {
261    if let Ok(secret) = std::env::var("GREENTIC_TUNNEL_SECRET")
262        && !secret.is_empty()
263    {
264        return secret;
265    }
266    resolve_tunnel_secret_in(&tunnel_state_root(), tunnel_id)
267}
268
269/// File-store half of [`resolve_tunnel_secret`], root passed explicitly so it is
270/// testable without touching the developer's real `~/.greentic/tunnel`.
271fn resolve_tunnel_secret_in(root: &Path, tunnel_id: &str) -> String {
272    read_secret_file(root.join("secrets").join(tunnel_id))
273        .or_else(|| read_secret_file(root.join("secret")))
274        .unwrap_or_else(|| DEFAULT_TUNNEL_SECRET.to_string())
275}
276
277fn gtunnel_ctx_from_env() -> GtunnelSetupCtx {
278    GtunnelSetupCtx::new(
279        std::env::var("GREENTIC_TUNNEL_ID").unwrap_or_else(|_| "default".to_string()),
280    )
281}
282
283pub fn should_start_setup_tunnel(mode: &str, answers: &JsonMap<String, Value>) -> bool {
284    matches!(mode, "cloudflared" | "ngrok" | "gtunnel")
285        && answers.values().any(|provider_answers| {
286            let Some(obj) = provider_answers.as_object() else {
287                return false;
288            };
289            crate::provider_state::provider_enabled_from_map(obj)
290                && !obj
291                    .get("public_base_url")
292                    .and_then(Value::as_str)
293                    .map(str::trim)
294                    .is_some_and(|value| {
295                        value.starts_with("https://") && !is_ephemeral_tunnel_url(value)
296                    })
297        })
298}
299
300pub fn start_setup_tunnel(
301    mode: &str,
302    local_base_url: &str,
303    gtunnel: Option<GtunnelSetupCtx>,
304) -> Result<SetupTunnel> {
305    match mode {
306        "cloudflared" => start_cloudflared_shared(local_base_url),
307        "ngrok" => {
308            let (child, url) = spawn_tunnel_process(mode, local_base_url)?;
309            Ok(SetupTunnel {
310                mode: mode.to_string(),
311                local_base_url: local_base_url.trim_end_matches('/').to_string(),
312                public_base_url: url,
313                child: Some(child),
314                kill_on_drop: true,
315            })
316        }
317        "gtunnel" => {
318            let ctx = gtunnel.unwrap_or_else(gtunnel_ctx_from_env);
319            start_gtunnel_shared(local_base_url, &ctx)
320        }
321        other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
322    }
323}
324
325/// Adopt (or spawn) the Greentic self-hosted tunnel agent under the machine-wide
326/// shared record for this port, so `greentic-start` reuses the same agent. The
327/// public URL is deterministic (`<worker>/<tunnel_id>`) — no discovery needed.
328fn start_gtunnel_shared(local_base_url: &str, ctx: &GtunnelSetupCtx) -> Result<SetupTunnel> {
329    let mode = "gtunnel";
330    let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
331        .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
332    let paths = crate::shared_tunnel::shared_service_tunnel_paths(mode, port);
333    let _lock =
334        crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(30))?;
335
336    let public_base_url = match &ctx.base_domain {
337        Some(base) => format!("https://{}.{}", ctx.tunnel_id, base.trim_matches('.')),
338        // Root-map: the Worker host itself is this tunnel — no path prefix.
339        None if ctx.root_map => ctx.worker_url.trim_end_matches('/').to_string(),
340        None => format!("{}/{}", ctx.worker_url.trim_end_matches('/'), ctx.tunnel_id),
341    };
342
343    // Reuse a live agent (ours or greentic-start's) rather than spawn a second —
344    // the Worker allows only one socket per tunnel id. But a pid can be alive
345    // while its WebSocket to the Worker is dead, so only adopt an agent that is
346    // actually SERVING; a stale one is killed and replaced (so the caller's
347    // reachability probe doesn't just fail on a reused-but-dead tunnel).
348    let (recorded_pid, _recorded_url) = crate::shared_tunnel::read_record(&paths);
349    if let Some(pid) = recorded_pid
350        && crate::shared_tunnel::process_alive(pid)
351    {
352        if gtunnel_serving(&public_base_url) {
353            eprintln!("Reusing shared {mode} agent (pid {pid}): {public_base_url}");
354            let _ = crate::shared_tunnel::write_record(&paths, pid, &public_base_url);
355            return Ok(reuse_shared_tunnel(mode, local_base_url, public_base_url));
356        }
357        eprintln!(
358            "Shared {mode} agent (pid {pid}) is alive but {public_base_url} is not serving — \
359             replacing the stale tunnel"
360        );
361        crate::shared_tunnel::terminate_recorded_pid_named(pid, "greentic-start");
362    }
363    crate::shared_tunnel::clear_record(&paths);
364
365    let child = spawn_gtunnel_agent(local_base_url, ctx, &paths.log_path)?;
366    if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &public_base_url) {
367        eprintln!("warning: could not publish shared gtunnel record: {err:#}");
368    }
369    eprintln!("Setup tunnel started via {mode}: {public_base_url}");
370    Ok(SetupTunnel {
371        mode: mode.to_string(),
372        local_base_url: local_base_url.trim_end_matches('/').to_string(),
373        public_base_url,
374        child: Some(child),
375        kill_on_drop: false,
376    })
377}
378
379/// Whether the tunnel serves end to end: a bounded GET to the public URL. A
380/// routed response (2xx/3xx/4xx) proves the agent is connected and forwarding;
381/// the Worker's `502 tunnel offline` (or any 5xx / transport error) means the
382/// recorded agent is stale. `< 500` mirrors `setup_backend_public_tunnel_responds`.
383fn gtunnel_serving(public_url: &str) -> bool {
384    let agent = ureq::Agent::config_builder()
385        .timeout_global(Some(Duration::from_secs(5)))
386        .build()
387        .new_agent();
388    match agent.get(public_url).call() {
389        Ok(_) => true,
390        Err(ureq::Error::StatusCode(code)) => code < 500,
391        Err(_) => false,
392    }
393}
394
395/// Spawn `greentic-start __tunnel-agent`, forwarding to `local_base_url`, with
396/// stdout/stderr redirected to the shared tunnel log.
397fn spawn_gtunnel_agent(
398    local_base_url: &str,
399    ctx: &GtunnelSetupCtx,
400    log_path: &Path,
401) -> Result<Child> {
402    let binary = resolve_gtunnel_agent_binary()?;
403    if let Some(parent) = log_path.parent() {
404        std::fs::create_dir_all(parent).ok();
405    }
406    let log = std::fs::OpenOptions::new()
407        .create(true)
408        .append(true)
409        .open(log_path)
410        .with_context(|| format!("open gtunnel log {}", log_path.display()))?;
411    let log_err = log
412        .try_clone()
413        .with_context(|| "clone gtunnel log handle")?;
414
415    let edge_url = match &ctx.base_domain {
416        // Subdomain routing: register at the tunnel's own host root.
417        Some(base) => format!("wss://{}.{}/_tunnel", ctx.tunnel_id, base.trim_matches('.')),
418        None => {
419            let ws_base = if let Some(rest) = ctx
420                .worker_url
421                .trim_end_matches('/')
422                .strip_prefix("https://")
423            {
424                format!("wss://{rest}")
425            } else if let Some(rest) = ctx.worker_url.trim_end_matches('/').strip_prefix("http://")
426            {
427                format!("ws://{rest}")
428            } else {
429                ctx.worker_url.trim_end_matches('/').to_string()
430            };
431            if ctx.root_map {
432                // Root-map: register at the Worker host root; the Worker maps
433                // every request (including /_tunnel) to TUNNEL_DEFAULT_ID.
434                format!("{ws_base}/_tunnel")
435            } else {
436                format!("{ws_base}/{}/_tunnel", ctx.tunnel_id)
437            }
438        }
439    };
440
441    Command::new(&binary)
442        .arg("__tunnel-agent")
443        .env("GREENTIC_TUNNEL_EDGE_URL", edge_url)
444        .env("GREENTIC_TUNNEL_SECRET", &ctx.secret)
445        .env(
446            "GREENTIC_TUNNEL_TARGET",
447            local_base_url.trim_end_matches('/'),
448        )
449        .stdout(Stdio::from(log))
450        .stderr(Stdio::from(log_err))
451        .spawn()
452        .with_context(|| format!("spawn gtunnel agent via {}", binary.display()))
453}
454
455/// Locate the `greentic-start` binary that hosts the `__tunnel-agent` subcommand:
456/// explicit `GREENTIC_TUNNEL_AGENT_BIN`, else the first `greentic-start` on PATH.
457fn resolve_gtunnel_agent_binary() -> Result<PathBuf> {
458    if let Some(explicit) = std::env::var_os("GREENTIC_TUNNEL_AGENT_BIN") {
459        let path = PathBuf::from(explicit);
460        if path.exists() {
461            return Ok(path);
462        }
463        return Err(anyhow!(
464            "GREENTIC_TUNNEL_AGENT_BIN points at {}, which does not exist",
465            path.display()
466        ));
467    }
468    resolve_path_binary("greentic-start").ok_or_else(|| {
469        anyhow!(
470            "greentic-start not found on PATH (needed to run the tunnel agent); \
471             set GREENTIC_TUNNEL_AGENT_BIN to its location"
472        )
473    })
474}
475
476/// Build a [`SetupTunnel`] that reuses an already-running shared tunnel: there
477/// is no child to own and nothing to kill on drop — the tunnel deliberately
478/// outlives this setup process so the runtime it configures keeps the same URL.
479fn reuse_shared_tunnel(mode: &str, local_base_url: &str, public_base_url: String) -> SetupTunnel {
480    SetupTunnel::detached(mode, local_base_url, &public_base_url)
481}
482
483/// Acquire the machine-wide shared cloudflared tunnel for the port behind
484/// `local_base_url`: reuse the recorded one when it still serves, otherwise
485/// spawn a fresh cloudflared and publish it so greentic-start adopts the same
486/// tunnel instead of racing it (see [`crate::shared_tunnel`]).
487fn start_cloudflared_shared(local_base_url: &str) -> Result<SetupTunnel> {
488    let mode = "cloudflared";
489    let port = crate::shared_tunnel::local_port_from_base_url(local_base_url)
490        .ok_or_else(|| anyhow!("cannot derive a local port from {local_base_url}"))?;
491    let paths = crate::shared_tunnel::shared_tunnel_paths(port);
492    let _lock =
493        crate::shared_tunnel::TunnelLock::acquire(&paths.lock_path, Duration::from_secs(45))?;
494
495    use crate::shared_tunnel::RecordedTunnelState;
496    let (recorded_pid, recorded_url) = crate::shared_tunnel::read_record(&paths);
497    eprintln!(
498        "Setup tunnel: checking shared cloudflared record for port {port} \
499         (recorded pid={recorded_pid:?}, url={recorded_url:?})"
500    );
501    if let Some(url) = recorded_url {
502        match crate::shared_tunnel::classify_recorded_tunnel(&paths, recorded_pid, &url) {
503            RecordedTunnelState::Serving | RecordedTunnelState::WarmingUp => {
504                eprintln!("Reusing shared {mode} tunnel: {url}");
505                return Ok(reuse_shared_tunnel(mode, local_base_url, url));
506            }
507            RecordedTunnelState::Down => {
508                // Recorded tunnel is genuinely gone (process dead, or the edge
509                // returned 530 for a lost binding). It is ours to replace: the
510                // pid came from the shared record, never a process-name match.
511                eprintln!("Shared {mode} tunnel {url} is down; replacing it");
512                if let Some(pid) = recorded_pid {
513                    crate::shared_tunnel::terminate_recorded_pid(pid);
514                }
515            }
516        }
517    }
518    crate::shared_tunnel::clear_record(&paths);
519
520    let (child, url) = spawn_cloudflared_logged(local_base_url, &paths.log_path)?;
521    if let Err(err) = crate::shared_tunnel::write_record(&paths, child.id(), &url) {
522        eprintln!("warning: could not publish shared tunnel record: {err:#}");
523    }
524    eprintln!("Setup tunnel started via {mode}: {url}");
525    Ok(SetupTunnel {
526        mode: mode.to_string(),
527        local_base_url: local_base_url.trim_end_matches('/').to_string(),
528        public_base_url: url,
529        child: Some(child),
530        kill_on_drop: false,
531    })
532}
533
534/// Spawn cloudflared with stdout/stderr redirected to the shared log file and
535/// discover the tunnel URL by polling that file.
536///
537/// Deliberately NOT piped: cloudflared is a Go binary, and Go processes die
538/// on SIGPIPE when writing logs to a closed stdout/stderr pipe — a piped
539/// tunnel would be killed the moment setup exits, defeating the
540/// outlive-setup handoff. A log file keeps it alive and doubles as
541/// greentic-start's fallback URL-discovery source.
542fn spawn_cloudflared_logged(local_base_url: &str, log_path: &Path) -> Result<(Child, String)> {
543    let binary = resolve_tunnel_binary("cloudflared")?;
544    if let Some(parent) = log_path.parent() {
545        std::fs::create_dir_all(parent)
546            .with_context(|| format!("create tunnel log dir {}", parent.display()))?;
547    }
548    // Truncate: URL discovery must not read a previous tunnel's URL.
549    let log = std::fs::File::create(log_path)
550        .with_context(|| format!("create tunnel log {}", log_path.display()))?;
551    let log_err = log
552        .try_clone()
553        .with_context(|| format!("clone tunnel log handle {}", log_path.display()))?;
554
555    let mut child = Command::new(binary)
556        .args(["tunnel", "--url", local_base_url, "--no-autoupdate"])
557        .stdout(Stdio::from(log))
558        .stderr(Stdio::from(log_err))
559        .spawn()
560        .with_context(|| "start cloudflared setup tunnel")?;
561
562    let deadline = std::time::Instant::now() + Duration::from_secs(25);
563    while std::time::Instant::now() < deadline {
564        if let Some(status) = child.try_wait()? {
565            return Err(anyhow!(
566                "cloudflared exited before publishing a URL: {status} (log: {})",
567                log_path.display()
568            ));
569        }
570        if let Ok(contents) = std::fs::read_to_string(log_path)
571            && let Some(url) = extract_tunnel_https_url("cloudflared", &contents)
572        {
573            return Ok((child, url));
574        }
575        std::thread::sleep(Duration::from_millis(250));
576    }
577
578    let _ = child.kill();
579    let _ = child.wait();
580    Err(anyhow!(
581        "cloudflared did not publish an https:// URL within 25 seconds (log: {})",
582        log_path.display()
583    ))
584}
585
586/// Spawn the tunnel binary and read its stdout/stderr until it publishes an
587/// https:// URL for its mode.
588fn spawn_tunnel_process(mode: &str, local_base_url: &str) -> Result<(Child, String)> {
589    let mut command = match mode {
590        "cloudflared" => {
591            let binary = resolve_tunnel_binary(mode)?;
592            let mut command = Command::new(binary);
593            command.args(["tunnel", "--url", local_base_url, "--no-autoupdate"]);
594            command
595        }
596        "ngrok" => {
597            let binary = resolve_tunnel_binary(mode)?;
598            let mut command = Command::new(binary);
599            command.args(["http", local_base_url, "--log=stdout"]);
600            command
601        }
602        other => return Err(anyhow!("unsupported setup tunnel mode: {other}")),
603    };
604    command.stdout(Stdio::piped()).stderr(Stdio::piped());
605    let mut child = command
606        .spawn()
607        .with_context(|| format!("start {mode} setup tunnel"))?;
608
609    let (tx, rx) = std::sync::mpsc::channel::<String>();
610    if let Some(stdout) = child.stdout.take() {
611        spawn_tunnel_log_reader(stdout, tx.clone());
612    }
613    if let Some(stderr) = child.stderr.take() {
614        spawn_tunnel_log_reader(stderr, tx.clone());
615    }
616    drop(tx);
617
618    let deadline = std::time::Instant::now() + Duration::from_secs(25);
619    while std::time::Instant::now() < deadline {
620        if let Some(status) = child.try_wait()? {
621            return Err(anyhow!("{mode} exited before publishing a URL: {status}"));
622        }
623        match rx.recv_timeout(Duration::from_millis(250)) {
624            Ok(line) => {
625                if let Some(url) = extract_tunnel_https_url(mode, &line) {
626                    eprintln!("Setup tunnel started via {mode}: {url}");
627                    return Ok((child, url));
628                }
629            }
630            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
631            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
632        }
633    }
634
635    let _ = child.kill();
636    let _ = child.wait();
637    Err(anyhow!(
638        "{mode} did not publish an https:// URL within 25 seconds"
639    ))
640}
641
642fn resolve_tunnel_binary(mode: &str) -> Result<PathBuf> {
643    match mode {
644        "cloudflared" => resolve_cloudflared_binary(),
645        "ngrok" => resolve_path_binary("ngrok")
646            .ok_or_else(|| anyhow!("ngrok is not installed or not on PATH")),
647        other => Err(anyhow!("unsupported setup tunnel mode: {other}")),
648    }
649}
650
651fn resolve_cloudflared_binary() -> Result<PathBuf> {
652    if let Some(binary) = resolve_path_binary("cloudflared") {
653        return Ok(binary);
654    }
655
656    let binary = managed_tunnel_binary_path("cloudflared");
657    if executable_exists(&binary) {
658        return Ok(binary);
659    }
660
661    install_cloudflared_binary(&binary)?;
662    Ok(binary)
663}
664
665fn resolve_path_binary(name: &str) -> Option<PathBuf> {
666    let paths = std::env::var_os("PATH")?;
667    std::env::split_paths(&paths)
668        .map(|dir| dir.join(platform_executable_name(name)))
669        .find(|candidate| executable_exists(candidate))
670}
671
672fn managed_tunnel_binary_path(name: &str) -> PathBuf {
673    let base_dir = std::env::var_os("GREENTIC_SETUP_BIN_DIR")
674        .map(PathBuf::from)
675        .or_else(|| {
676            std::env::var_os("HOME")
677                .map(PathBuf::from)
678                .map(|home| home.join(".cache").join("greentic-setup").join("bin"))
679        })
680        .unwrap_or_else(|| std::env::temp_dir().join("greentic-setup").join("bin"));
681    base_dir.join(platform_executable_name(name))
682}
683
684fn platform_executable_name(name: &str) -> String {
685    if cfg!(windows) {
686        format!("{name}.exe")
687    } else {
688        name.to_string()
689    }
690}
691
692fn executable_exists(path: &Path) -> bool {
693    if !path.is_file() {
694        return false;
695    }
696    #[cfg(unix)]
697    {
698        use std::os::unix::fs::PermissionsExt;
699        std::fs::metadata(path)
700            .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
701            .unwrap_or(false)
702    }
703    #[cfg(not(unix))]
704    {
705        true
706    }
707}
708
709fn install_cloudflared_binary(target: &Path) -> Result<()> {
710    let asset = cloudflared_release_asset()
711        .ok_or_else(|| anyhow!("cloudflared auto-install is unsupported on this platform"))?;
712    let download_url =
713        format!("https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}");
714
715    let parent = target
716        .parent()
717        .ok_or_else(|| anyhow!("invalid managed cloudflared path {}", target.display()))?;
718    std::fs::create_dir_all(parent)
719        .with_context(|| format!("create tunnel binary cache {}", parent.display()))?;
720    let temp_path = target.with_extension(format!("download-{}", std::process::id()));
721    let bytes = download_bytes(&download_url)
722        .with_context(|| format!("download cloudflared release asset {asset}"))?;
723    if asset.ends_with(".tgz") {
724        extract_cloudflared_tgz(&bytes, target)?;
725    } else {
726        std::fs::write(&temp_path, bytes)
727            .with_context(|| format!("write {}", temp_path.display()))?;
728        finalize_installed_binary(&temp_path, target)?;
729    }
730
731    Ok(())
732}
733
734fn download_bytes(url: &str) -> Result<Vec<u8>> {
735    let mut response = crate::http_client::download_agent()
736        .get(url)
737        .call()
738        .map_err(|err| anyhow!("request {url}: {err}"))?;
739    response
740        .body_mut()
741        .with_config()
742        .limit(64 * 1024 * 1024)
743        .read_to_vec()
744        .map_err(|err| anyhow!("read {url}: {err}"))
745}
746
747fn extract_cloudflared_tgz(bytes: &[u8], target: &Path) -> Result<()> {
748    let temp_path = target.with_extension(format!("download-{}", std::process::id()));
749    let decoder = flate2::read::GzDecoder::new(std::io::Cursor::new(bytes));
750    let mut archive = tar::Archive::new(decoder);
751    for entry in archive.entries().context("read cloudflared archive")? {
752        let mut entry = entry.context("read cloudflared archive entry")?;
753        let path = entry.path().context("read cloudflared archive path")?;
754        let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
755            continue;
756        };
757        if name == "cloudflared" || name == "cloudflared.exe" {
758            let mut output = std::fs::File::create(&temp_path)
759                .with_context(|| format!("create {}", temp_path.display()))?;
760            std::io::copy(&mut entry, &mut output)
761                .with_context(|| format!("extract {}", temp_path.display()))?;
762            finalize_installed_binary(&temp_path, target)?;
763            return Ok(());
764        }
765    }
766    Err(anyhow!("cloudflared archive did not contain a binary"))
767}
768
769fn finalize_installed_binary(temp_path: &Path, target: &Path) -> Result<()> {
770    #[cfg(unix)]
771    {
772        use std::os::unix::fs::PermissionsExt;
773        let mut permissions = std::fs::metadata(temp_path)
774            .with_context(|| format!("stat {}", temp_path.display()))?
775            .permissions();
776        permissions.set_mode(0o755);
777        std::fs::set_permissions(temp_path, permissions)
778            .with_context(|| format!("chmod {}", temp_path.display()))?;
779    }
780    std::fs::rename(temp_path, target)
781        .with_context(|| format!("install cloudflared to {}", target.display()))?;
782    Ok(())
783}
784
785fn cloudflared_release_asset() -> Option<&'static str> {
786    match (std::env::consts::OS, std::env::consts::ARCH) {
787        ("macos", "aarch64") => Some("cloudflared-darwin-arm64.tgz"),
788        ("macos", "x86_64") => Some("cloudflared-darwin-amd64.tgz"),
789        ("linux", "aarch64") => Some("cloudflared-linux-arm64"),
790        ("linux", "x86_64") => Some("cloudflared-linux-amd64"),
791        ("windows", "x86_64") => Some("cloudflared-windows-amd64.exe"),
792        ("windows", "x86") => Some("cloudflared-windows-386.exe"),
793        _ => None,
794    }
795}
796
797fn spawn_tunnel_log_reader<R>(stream: R, tx: std::sync::mpsc::Sender<String>)
798where
799    R: std::io::Read + Send + 'static,
800{
801    std::thread::spawn(move || {
802        use std::io::BufRead;
803        let reader = std::io::BufReader::new(stream);
804        for line in reader.lines().map_while(std::result::Result::ok) {
805            let _ = tx.send(line);
806        }
807    });
808}
809
810pub fn extract_tunnel_https_url(mode: &str, line: &str) -> Option<String> {
811    extract_https_urls(line)
812        .into_iter()
813        .find(|url| tunnel_url_matches_mode(mode, url))
814}
815
816fn tunnel_url_matches_mode(mode: &str, url: &str) -> bool {
817    let Ok(parsed) = url::Url::parse(url) else {
818        return false;
819    };
820    if parsed.scheme() != "https" {
821        return false;
822    }
823    let Some(host) = parsed.host_str() else {
824        return false;
825    };
826    match mode {
827        "cloudflared" => host == "trycloudflare.com" || host.ends_with(".trycloudflare.com"),
828        "ngrok" => host.ends_with(".ngrok-free.app") || host.ends_with(".ngrok.io"),
829        _ => false,
830    }
831}
832
833fn extract_https_urls(line: &str) -> Vec<String> {
834    let mut urls = Vec::new();
835    let mut offset = 0;
836    while let Some(start) = line[offset..].find("https://") {
837        let absolute_start = offset + start;
838        let tail = &line[absolute_start..];
839        let end = tail
840            .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | ',' | ')'))
841            .unwrap_or(tail.len());
842        urls.push(tail[..end].trim_end_matches('/').to_string());
843        offset = absolute_start + end;
844    }
845    urls
846}
847
848pub fn inject_setup_public_base_url(answers: &mut JsonMap<String, Value>, public_base_url: &str) {
849    // The OAuth *callback* (developer app-install) is served by the setup server,
850    // not the runtime, so provider ops that register OAuth redirect URLs need the
851    // setup server's public URL — separate from the messaging `public_base_url`.
852    // Injected only when `GREENTIC_SETUP_PUBLIC_BASE_URL` is set; otherwise ops
853    // fall back to `public_base_url` for back-compat.
854    let oauth_callback_base_url = std::env::var("GREENTIC_SETUP_PUBLIC_BASE_URL")
855        .ok()
856        .map(|value| value.trim().trim_end_matches('/').to_string())
857        .filter(|value| value.starts_with("https://"));
858    for provider_answers in answers.values_mut() {
859        let Some(obj) = provider_answers.as_object_mut() else {
860            continue;
861        };
862        if !crate::provider_state::provider_enabled_from_map(obj) {
863            continue;
864        }
865        if let Some(ref callback_base) = oauth_callback_base_url {
866            obj.insert(
867                "oauth_callback_base_url".to_string(),
868                Value::String(callback_base.clone()),
869            );
870        }
871        if obj
872            .get("public_base_url")
873            .and_then(Value::as_str)
874            .map(str::trim)
875            .is_some_and(|value| value.starts_with("https://") && !is_ephemeral_tunnel_url(value))
876        {
877            continue;
878        }
879        obj.insert(
880            "public_base_url".to_string(),
881            Value::String(public_base_url.to_string()),
882        );
883    }
884}
885
886/// Does `value` name a tunnel URL that SETUP HANDED OUT and may therefore
887/// re-point? Callers use it to tell an operator's permanent URL (preserve — never
888/// touch it) from one we produced ourselves (replace with the current one).
889///
890/// NOTE ON THE NAME: "ephemeral" here means "engine-assigned, ours to replace" —
891/// NOT "short-lived". The managed-tunnel Worker HOST is perfectly stable, yet the
892/// tunnel id in its first path segment changes between runs, so a managed URL is
893/// still refreshable and must be matched here. The name is kept because this is
894/// `pub` and consumed by three other modules, and because two payload keys embed
895/// it — `public_base_url_is_ephemeral_tunnel` (persisted in `runtime_context` and
896/// compared against earlier runs) and `"ephemeral"` in the setup-machine step
897/// detail. Renaming the function without those keys would mismatch, and renaming
898/// the keys would invalidate already-persisted state for no behavioural gain.
899///
900/// Before this matched the Worker host, a STALE managed URL looked permanent and
901/// was preserved forever: one real session left Webex and the state provider on
902/// `<worker>/default` while Slack moved to `<worker>/default-default`, and the
903/// webhook Webex had registered pointed at a path the tunnel no longer served.
904pub fn is_ephemeral_tunnel_url(value: &str) -> bool {
905    is_ephemeral_tunnel_url_for_worker_base(value, &gtunnel_worker_base_url())
906}
907
908/// Worker-base half of [`is_ephemeral_tunnel_url`], with the base passed
909/// explicitly so it is testable without mutating process env — the same pattern
910/// [`resolve_tunnel_secret_in`] uses for the state root.
911fn is_ephemeral_tunnel_url_for_worker_base(value: &str, worker_base_url: &str) -> bool {
912    // Derived from the configured Worker base rather than a hardcoded hostname,
913    // so a self-hosted Worker is treated exactly like the default one.
914    let managed_host = url::Url::parse(worker_base_url)
915        .ok()
916        .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()));
917    url::Url::parse(value).ok().is_some_and(|url| {
918        url.scheme() == "https"
919            && url.host_str().is_some_and(|host| {
920                let host = host.to_ascii_lowercase();
921                host == "trycloudflare.com"
922                    || host.ends_with(".trycloudflare.com")
923                    || host.ends_with(".ngrok-free.app")
924                    || host.ends_with(".ngrok.io")
925                    // Any URL on the managed Worker host is ours: the host does
926                    // not distinguish tunnel ids, the path segment does, and that
927                    // segment is exactly what goes stale.
928                    || managed_host.as_deref() == Some(host.as_str())
929            })
930    })
931}
932
933#[cfg(test)]
934mod tests {
935    use std::path::Path;
936
937    use serde_json::{Map as JsonMap, Value, json};
938
939    use super::*;
940
941    #[test]
942    fn default_tunnel_secret_is_64_hex_chars() {
943        assert_eq!(DEFAULT_TUNNEL_SECRET.len(), 64, "256-bit secret as hex");
944        assert!(DEFAULT_TUNNEL_SECRET.chars().all(|c| c.is_ascii_hexdigit()));
945    }
946
947    #[test]
948    fn resolve_tunnel_secret_falls_back_to_baked_in_constant() {
949        // Empty store → the shipped constant, never empty: setup must never
950        // demand credentials for the managed tunnel.
951        let dir = tempfile::tempdir().expect("tempdir");
952        assert_eq!(
953            resolve_tunnel_secret_in(dir.path(), "demo-default"),
954            DEFAULT_TUNNEL_SECRET
955        );
956    }
957
958    #[test]
959    fn resolve_tunnel_secret_prefers_per_tunnel_file_then_operator_file() {
960        let dir = tempfile::tempdir().expect("tempdir");
961        std::fs::write(dir.path().join("secret"), "operator-secret\n").expect("write operator");
962        assert_eq!(
963            resolve_tunnel_secret_in(dir.path(), "demo-default"),
964            "operator-secret"
965        );
966
967        std::fs::create_dir_all(dir.path().join("secrets")).expect("mkdir");
968        std::fs::write(
969            dir.path().join("secrets").join("demo-default"),
970            "per-tunnel",
971        )
972        .expect("write per-tunnel");
973        assert_eq!(
974            resolve_tunnel_secret_in(dir.path(), "demo-default"),
975            "per-tunnel"
976        );
977    }
978
979    #[test]
980    fn sanitize_tunnel_id_uses_the_tenant_alone() {
981        // The BASE is the tenant alone — team is ignored, so it can equal the
982        // <tenant> segment of /v1/web/webchat/<tenant>/… (which has no team
983        // segment) and match greentic-start's sanitize_tunnel_id.
984        assert_eq!(sanitize_tunnel_id("Acme Corp"), "acme-corp");
985        assert_eq!(sanitize_tunnel_id("demo"), "demo");
986        assert_eq!(sanitize_tunnel_id(""), "default");
987        // Each non-alphanumeric folds to its own `-`, so runs are preserved
988        // and only the edges are trimmed.
989        assert_eq!(sanitize_tunnel_id("--Weird__Tenant--"), "weird--tenant");
990    }
991
992    #[test]
993    fn derive_gtunnel_id_appends_a_clash_suffix_to_the_base() {
994        // The full id carries a per-install suffix: the managed tunnel is ONE
995        // shared Worker and nearly every operator runs the default tenant, so a
996        // bare `<tenant>` id would collide across installs.
997        let id = derive_gtunnel_id("demo", "default");
998        let (base, suffix) = id.rsplit_once('-').expect("id carries a suffix");
999        assert_eq!(base, "demo");
1000        assert_eq!(suffix.len(), 5, "5-hex suffix, got {id}");
1001        assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()), "{id}");
1002    }
1003
1004    #[test]
1005    fn derive_gtunnel_id_ignores_team_entirely() {
1006        // Guards the regression directly: any team must collapse to one id, so a
1007        // second team on the same tenant cannot strand a registered webhook URL.
1008        let ids: std::collections::BTreeSet<String> = ["default", "eng", "", "Team B"]
1009            .iter()
1010            .map(|team| derive_gtunnel_id("acme", team))
1011            .collect();
1012        assert_eq!(
1013            ids.len(),
1014            1,
1015            "team must not influence the tunnel id: {ids:?}"
1016        );
1017        assert!(
1018            ids.iter().next().expect("one id").starts_with("acme-"),
1019            "{ids:?}"
1020        );
1021    }
1022
1023    // ---- per-install clash suffix ----
1024
1025    #[test]
1026    fn clash_suffix_is_stable_across_calls_and_distinct_per_tenant() {
1027        // Stability is the whole point: the suffix ends up inside URLs registered
1028        // with Slack/Webex/OAuth, so it must survive restarts. It previously came
1029        // from a per-process nonce and changed on every start.
1030        let dir = tempfile::tempdir().expect("tempdir");
1031        let first = install_clash_suffix(dir.path(), "demo").expect("suffix");
1032        let second = install_clash_suffix(dir.path(), "demo").expect("suffix");
1033        assert_eq!(first, second, "same install + tenant must give one suffix");
1034        assert_eq!(first.len(), 5);
1035        assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
1036
1037        // Distinct per tenant, so two tenants on one install do not collide.
1038        let other = install_clash_suffix(dir.path(), "acme").expect("suffix");
1039        assert_ne!(first, other, "suffix must be tenant-scoped");
1040    }
1041
1042    #[test]
1043    fn clash_suffix_differs_across_installs() {
1044        // Two installs must not land on the same public URL — that is the
1045        // collision this exists to prevent.
1046        let a = tempfile::tempdir().expect("tempdir");
1047        let b = tempfile::tempdir().expect("tempdir");
1048        assert_ne!(
1049            install_clash_suffix(a.path(), "default").expect("suffix"),
1050            install_clash_suffix(b.path(), "default").expect("suffix"),
1051            "distinct seeds must yield distinct suffixes"
1052        );
1053    }
1054
1055    #[test]
1056    fn instance_seed_is_persisted_and_reused() {
1057        let dir = tempfile::tempdir().expect("tempdir");
1058        let first = load_or_create_instance_seed(dir.path()).expect("seed");
1059        assert_eq!(first.len(), 64, "256-bit seed as hex");
1060        assert!(dir.path().join("instance-seed").is_file(), "must persist");
1061        assert_eq!(
1062            load_or_create_instance_seed(dir.path()).as_deref(),
1063            Some(first.as_str()),
1064            "a second read must reuse the persisted seed, not mint a new one"
1065        );
1066    }
1067
1068    #[test]
1069    fn corrupt_seed_file_is_replaced_rather_than_used() {
1070        let dir = tempfile::tempdir().expect("tempdir");
1071        std::fs::write(dir.path().join("instance-seed"), "not-a-seed\n").expect("write");
1072        let seed = load_or_create_instance_seed(dir.path()).expect("seed");
1073        assert_eq!(seed.len(), 64);
1074        assert!(seed.chars().all(|c| c.is_ascii_hexdigit()));
1075    }
1076
1077    // ---- should_start_setup_tunnel ----
1078
1079    #[test]
1080    fn setup_tunnel_helpers_detect_public_url_need() {
1081        let empty_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1082            "messaging-slack": {}
1083        }))
1084        .expect("answers");
1085        assert!(should_start_setup_tunnel("cloudflared", &empty_answers));
1086
1087        let https_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1088            "messaging-slack": {
1089                "public_base_url": "https://operator.example.com"
1090            }
1091        }))
1092        .expect("answers");
1093        assert!(!should_start_setup_tunnel("cloudflared", &https_answers));
1094        let stale_tunnel_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1095            "messaging-slack": {
1096                "public_base_url": "https://old.trycloudflare.com"
1097            }
1098        }))
1099        .expect("answers");
1100        assert!(should_start_setup_tunnel(
1101            "cloudflared",
1102            &stale_tunnel_answers
1103        ));
1104        assert!(!should_start_setup_tunnel("off", &empty_answers));
1105        let disabled_answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1106            "messaging-slack": {
1107                "enabled": false
1108            }
1109        }))
1110        .expect("answers");
1111        assert!(!should_start_setup_tunnel("cloudflared", &disabled_answers));
1112
1113        assert_eq!(
1114            extract_tunnel_https_url(
1115                "cloudflared",
1116                "INF tunnel running at https://demo.trycloudflare.com"
1117            ),
1118            Some("https://demo.trycloudflare.com".to_string())
1119        );
1120        assert_eq!(
1121            extract_tunnel_https_url("ngrok", "url=https://demo.ngrok-free.app latency=1ms"),
1122            Some("https://demo.ngrok-free.app".to_string())
1123        );
1124        assert_eq!(
1125            extract_tunnel_https_url(
1126                "cloudflared",
1127                "Terms: https://www.cloudflare.com/website-terms tunnel https://demo.trycloudflare.com"
1128            ),
1129            Some("https://demo.trycloudflare.com".to_string())
1130        );
1131        assert_eq!(
1132            extract_tunnel_https_url(
1133                "cloudflared",
1134                "Terms: https://www.cloudflare.com/website-terms"
1135            ),
1136            None
1137        );
1138        assert_eq!(
1139            extract_tunnel_https_url(
1140                "ngrok",
1141                "Forwarding https://demo.ngrok-free.app -> http://127.0.0.1:1234"
1142            ),
1143            Some("https://demo.ngrok-free.app".to_string())
1144        );
1145    }
1146
1147    #[test]
1148    fn should_start_tunnel_ngrok_mode() {
1149        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1150            "messaging-slack": {}
1151        }))
1152        .expect("answers");
1153        assert!(should_start_setup_tunnel("ngrok", &answers));
1154    }
1155
1156    #[test]
1157    fn should_start_tunnel_non_object_value_ignored() {
1158        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1159            "messaging-slack": "not-an-object"
1160        }))
1161        .expect("answers");
1162        assert!(!should_start_setup_tunnel("cloudflared", &answers));
1163    }
1164
1165    #[test]
1166    fn should_start_tunnel_whitespace_only_url_needs_tunnel() {
1167        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1168            "messaging-slack": {
1169                "public_base_url": "   "
1170            }
1171        }))
1172        .expect("answers");
1173        assert!(should_start_setup_tunnel("cloudflared", &answers));
1174    }
1175
1176    #[test]
1177    fn should_start_tunnel_http_url_needs_tunnel() {
1178        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1179            "messaging-slack": {
1180                "public_base_url": "http://127.0.0.1:8080"
1181            }
1182        }))
1183        .expect("answers");
1184        assert!(should_start_setup_tunnel("cloudflared", &answers));
1185    }
1186
1187    #[test]
1188    fn should_start_tunnel_stale_ngrok_url_needs_tunnel() {
1189        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1190            "messaging-telegram": {
1191                "public_base_url": "https://stale.ngrok-free.app"
1192            }
1193        }))
1194        .expect("answers");
1195        assert!(should_start_setup_tunnel("ngrok", &answers));
1196    }
1197
1198    #[test]
1199    fn should_start_tunnel_mixed_providers_one_needs_tunnel() {
1200        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1201            "messaging-teams": {
1202                "public_base_url": "https://stable.example.com"
1203            },
1204            "messaging-slack": {
1205                "public_base_url": "http://localhost:3000"
1206            }
1207        }))
1208        .expect("answers");
1209        // One provider has http, so tunnel is needed.
1210        assert!(should_start_setup_tunnel("cloudflared", &answers));
1211    }
1212
1213    #[test]
1214    fn should_start_tunnel_all_have_stable_https() {
1215        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1216            "messaging-teams": {
1217                "public_base_url": "https://stable.example.com"
1218            },
1219            "messaging-slack": {
1220                "public_base_url": "https://prod.example.com"
1221            }
1222        }))
1223        .expect("answers");
1224        assert!(!should_start_setup_tunnel("cloudflared", &answers));
1225    }
1226
1227    #[test]
1228    fn should_start_tunnel_empty_answers_map() {
1229        let answers = JsonMap::new();
1230        // No providers at all: no one needs a tunnel.
1231        assert!(!should_start_setup_tunnel("cloudflared", &answers));
1232    }
1233
1234    // ---- extract_https_urls ----
1235
1236    #[test]
1237    fn extract_https_urls_empty_line() {
1238        assert!(extract_https_urls("").is_empty());
1239    }
1240
1241    #[test]
1242    fn extract_https_urls_no_urls() {
1243        assert!(extract_https_urls("just some text without urls").is_empty());
1244    }
1245
1246    #[test]
1247    fn extract_https_urls_single_url() {
1248        let urls = extract_https_urls("visit https://example.com now");
1249        assert_eq!(urls, vec!["https://example.com"]);
1250    }
1251
1252    #[test]
1253    fn extract_https_urls_trailing_slash_stripped() {
1254        let urls = extract_https_urls("https://example.com/");
1255        assert_eq!(urls, vec!["https://example.com"]);
1256    }
1257
1258    #[test]
1259    fn extract_https_urls_multiple_urls() {
1260        let urls =
1261            extract_https_urls("first https://one.example.com then https://two.example.com end");
1262        assert_eq!(
1263            urls,
1264            vec!["https://one.example.com", "https://two.example.com"]
1265        );
1266    }
1267
1268    #[test]
1269    fn extract_https_urls_quoted_terminators() {
1270        let urls = extract_https_urls(r#""https://quoted.example.com""#);
1271        assert_eq!(urls, vec!["https://quoted.example.com"]);
1272
1273        let urls = extract_https_urls("'https://single-quoted.example.com'");
1274        assert_eq!(urls, vec!["https://single-quoted.example.com"]);
1275    }
1276
1277    #[test]
1278    fn extract_https_urls_angle_bracket_terminators() {
1279        let urls = extract_https_urls("<https://bracketed.example.com>");
1280        assert_eq!(urls, vec!["https://bracketed.example.com"]);
1281    }
1282
1283    #[test]
1284    fn extract_https_urls_comma_terminator() {
1285        let urls = extract_https_urls("https://a.com,https://b.com");
1286        assert_eq!(urls, vec!["https://a.com", "https://b.com"]);
1287    }
1288
1289    #[test]
1290    fn extract_https_urls_paren_terminator() {
1291        let urls = extract_https_urls("(https://paren.example.com)");
1292        assert_eq!(urls, vec!["https://paren.example.com"]);
1293    }
1294
1295    #[test]
1296    fn extract_https_urls_with_path() {
1297        let urls = extract_https_urls("at https://example.com/path/to/thing done");
1298        assert_eq!(urls, vec!["https://example.com/path/to/thing"]);
1299    }
1300
1301    #[test]
1302    fn extract_https_urls_ignores_http() {
1303        let urls = extract_https_urls("http://not-extracted.com https://extracted.com");
1304        assert_eq!(urls, vec!["https://extracted.com"]);
1305    }
1306
1307    // ---- tunnel_url_matches_mode ----
1308
1309    #[test]
1310    fn tunnel_url_matches_cloudflared_exact_host() {
1311        assert!(tunnel_url_matches_mode(
1312            "cloudflared",
1313            "https://trycloudflare.com"
1314        ));
1315    }
1316
1317    #[test]
1318    fn tunnel_url_matches_cloudflared_subdomain() {
1319        assert!(tunnel_url_matches_mode(
1320            "cloudflared",
1321            "https://abc-def.trycloudflare.com"
1322        ));
1323    }
1324
1325    #[test]
1326    fn tunnel_url_rejects_cloudflared_wrong_domain() {
1327        assert!(!tunnel_url_matches_mode(
1328            "cloudflared",
1329            "https://example.com"
1330        ));
1331    }
1332
1333    #[test]
1334    fn tunnel_url_matches_ngrok_free_app() {
1335        assert!(tunnel_url_matches_mode(
1336            "ngrok",
1337            "https://abc123.ngrok-free.app"
1338        ));
1339    }
1340
1341    #[test]
1342    fn tunnel_url_matches_ngrok_io() {
1343        assert!(tunnel_url_matches_mode("ngrok", "https://abc123.ngrok.io"));
1344    }
1345
1346    #[test]
1347    fn tunnel_url_rejects_ngrok_wrong_domain() {
1348        assert!(!tunnel_url_matches_mode("ngrok", "https://example.com"));
1349    }
1350
1351    #[test]
1352    fn tunnel_url_rejects_unknown_mode() {
1353        assert!(!tunnel_url_matches_mode(
1354            "unknown",
1355            "https://demo.trycloudflare.com"
1356        ));
1357    }
1358
1359    #[test]
1360    fn tunnel_url_rejects_http_scheme() {
1361        assert!(!tunnel_url_matches_mode(
1362            "cloudflared",
1363            "http://demo.trycloudflare.com"
1364        ));
1365    }
1366
1367    #[test]
1368    fn tunnel_url_rejects_malformed_url() {
1369        assert!(!tunnel_url_matches_mode("cloudflared", "not a url"));
1370    }
1371
1372    // ---- extract_tunnel_https_url (additional edge cases) ----
1373
1374    #[test]
1375    fn extract_tunnel_url_empty_line() {
1376        assert_eq!(extract_tunnel_https_url("cloudflared", ""), None);
1377    }
1378
1379    #[test]
1380    fn extract_tunnel_url_no_matching_domain() {
1381        assert_eq!(
1382            extract_tunnel_https_url("cloudflared", "https://unrelated.example.com"),
1383            None
1384        );
1385    }
1386
1387    #[test]
1388    fn extract_tunnel_url_ngrok_io_legacy() {
1389        assert_eq!(
1390            extract_tunnel_https_url("ngrok", "tunnel at https://abc.ngrok.io"),
1391            Some("https://abc.ngrok.io".to_string())
1392        );
1393    }
1394
1395    // ---- is_ephemeral_tunnel_url (additional edge cases) ----
1396
1397    #[test]
1398    fn ephemeral_url_http_not_ephemeral() {
1399        assert!(!is_ephemeral_tunnel_url("http://demo.trycloudflare.com"));
1400    }
1401
1402    #[test]
1403    fn ephemeral_url_trycloudflare_exact_root() {
1404        assert!(is_ephemeral_tunnel_url("https://trycloudflare.com"));
1405    }
1406
1407    #[test]
1408    fn ephemeral_url_ngrok_io_subdomain() {
1409        assert!(is_ephemeral_tunnel_url("https://deep.sub.ngrok.io/path"));
1410    }
1411
1412    #[test]
1413    fn ephemeral_url_malformed_not_ephemeral() {
1414        assert!(!is_ephemeral_tunnel_url("not-a-url"));
1415    }
1416
1417    #[test]
1418    fn ephemeral_url_mixed_case() {
1419        assert!(is_ephemeral_tunnel_url("https://DEMO.TryCloudflare.COM"));
1420    }
1421
1422    // ---- managed-tunnel (gtunnel) URLs are refreshable too ----
1423    //
1424    // The host is stable but the tunnel id in the first path segment is not, so a
1425    // managed URL from an earlier run must be re-pointed rather than preserved as
1426    // if an operator had supplied it.
1427
1428    #[test]
1429    fn managed_worker_url_is_refreshable_on_the_default_worker_host() {
1430        // Env-free: falls back to DEFAULT_GTUNNEL_WORKER_BASE_URL.
1431        assert!(is_ephemeral_tunnel_url(&format!(
1432            "{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default"
1433        )));
1434        assert!(is_ephemeral_tunnel_url(&format!(
1435            "{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default"
1436        )));
1437        // Bare host, no id segment at all.
1438        assert!(is_ephemeral_tunnel_url(DEFAULT_GTUNNEL_WORKER_BASE_URL));
1439    }
1440
1441    #[test]
1442    fn managed_worker_url_is_refreshable_on_a_self_hosted_worker_host() {
1443        // Worker base passed explicitly rather than via env: this crate has no
1444        // temp-env dev-dependency, and mutating process env would race the other
1445        // tests in this binary.
1446        assert!(is_ephemeral_tunnel_url_for_worker_base(
1447            "https://tunnel.acme.example/default",
1448            "https://tunnel.acme.example"
1449        ));
1450        // Host comparison is case-insensitive and ignores the base's trailing path.
1451        assert!(is_ephemeral_tunnel_url_for_worker_base(
1452            "https://Tunnel.ACME.example/demo",
1453            "https://tunnel.acme.example/"
1454        ));
1455    }
1456
1457    #[test]
1458    fn genuine_operator_url_is_still_preserved() {
1459        // The whole point of the predicate: a permanent operator-supplied ingress
1460        // must never be replaced by a tunnel URL.
1461        assert!(!is_ephemeral_tunnel_url("https://hooks.example.com"));
1462        assert!(!is_ephemeral_tunnel_url(
1463            "https://hooks.example.com/webhooks"
1464        ));
1465        // And it stays preserved when a self-hosted Worker is configured.
1466        assert!(!is_ephemeral_tunnel_url_for_worker_base(
1467            "https://hooks.example.com",
1468            "https://tunnel.acme.example"
1469        ));
1470    }
1471
1472    #[test]
1473    fn managed_worker_host_matches_the_configured_base_only() {
1474        // Documents the chosen semantics: the predicate keys on the CONFIGURED
1475        // Worker base, so with a self-hosted Worker a leftover URL on the default
1476        // Greentic Worker host is not claimed. Kept deliberately narrow — the
1477        // configured host is the one this run can actually serve.
1478        assert!(!is_ephemeral_tunnel_url_for_worker_base(
1479            &format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default"),
1480            "https://tunnel.acme.example"
1481        ));
1482        // A malformed worker base disables managed matching but must not break
1483        // the trycloudflare/ngrok arms.
1484        assert!(!is_ephemeral_tunnel_url_for_worker_base(
1485            "https://hooks.example.com",
1486            "not-a-url"
1487        ));
1488        assert!(is_ephemeral_tunnel_url_for_worker_base(
1489            "https://demo.trycloudflare.com",
1490            "not-a-url"
1491        ));
1492    }
1493
1494    #[test]
1495    fn inject_replaces_a_stale_managed_url_under_a_different_id() {
1496        // The live regression: an earlier run configured these providers under the
1497        // old `default-default` id, the current run serves `default`, and the URL
1498        // "looked stable" so it was preserved forever — leaving Webex's registered
1499        // webhook pointing at a path the tunnel no longer serves.
1500        let stale = format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default");
1501        let current = format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default");
1502        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1503            "messaging-webex": { "public_base_url": stale },
1504            "messaging-slack": { "public_base_url": current },
1505            "messaging-operator": { "public_base_url": "https://hooks.example.com" },
1506        }))
1507        .expect("answers");
1508
1509        inject_setup_public_base_url(&mut answers, &current);
1510
1511        assert_eq!(
1512            answers["messaging-webex"]["public_base_url"],
1513            json!(current),
1514            "a managed URL under the old id must be re-pointed at the current id"
1515        );
1516        assert_eq!(
1517            answers["messaging-slack"]["public_base_url"],
1518            json!(current)
1519        );
1520        assert_eq!(
1521            answers["messaging-operator"]["public_base_url"],
1522            json!("https://hooks.example.com"),
1523            "a genuine operator URL must survive untouched"
1524        );
1525    }
1526
1527    #[test]
1528    fn should_start_setup_tunnel_when_only_url_is_a_stale_managed_one() {
1529        let answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1530            "messaging-webex": {
1531                "public_base_url": format!("{DEFAULT_GTUNNEL_WORKER_BASE_URL}/default-default")
1532            },
1533        }))
1534        .expect("answers");
1535        assert!(
1536            should_start_setup_tunnel("gtunnel", &answers),
1537            "a stale managed URL must not convince setup the tunnel is unnecessary"
1538        );
1539
1540        // Contrast: a real operator URL genuinely needs no tunnel.
1541        let operator = serde_json::from_value::<JsonMap<String, Value>>(json!({
1542            "messaging-webex": { "public_base_url": "https://hooks.example.com" },
1543        }))
1544        .expect("answers");
1545        assert!(!should_start_setup_tunnel("gtunnel", &operator));
1546    }
1547
1548    // ---- inject_setup_public_base_url ----
1549
1550    #[test]
1551    fn setup_tunnel_url_overrides_missing_or_non_https_provider_answers() {
1552        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1553            "messaging-slack": {
1554                "public_base_url": "http://127.0.0.1:35519",
1555                "slack_configuration_access_token": "x"
1556            },
1557            "messaging-teams": {
1558                "public_base_url": "https://stable.example.com"
1559            },
1560            "messaging-stale-tunnel": {
1561                "public_base_url": "https://old.trycloudflare.com"
1562            },
1563            "messaging-disabled": {
1564                "enabled": false
1565            },
1566            "messaging-webhook": {}
1567        }))
1568        .expect("answers");
1569
1570        inject_setup_public_base_url(&mut answers, "https://setup.trycloudflare.com");
1571
1572        assert_eq!(
1573            answers["messaging-slack"]["public_base_url"],
1574            json!("https://setup.trycloudflare.com")
1575        );
1576        assert_eq!(
1577            answers["messaging-webhook"]["public_base_url"],
1578            json!("https://setup.trycloudflare.com")
1579        );
1580        assert_eq!(answers["messaging-disabled"].get("public_base_url"), None);
1581        assert_eq!(
1582            answers["messaging-teams"]["public_base_url"],
1583            json!("https://stable.example.com")
1584        );
1585        assert_eq!(
1586            answers["messaging-stale-tunnel"]["public_base_url"],
1587            json!("https://setup.trycloudflare.com")
1588        );
1589    }
1590
1591    #[test]
1592    fn inject_skips_non_object_values() {
1593        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1594            "scalar": "not-an-object",
1595            "array": [1, 2, 3],
1596            "null": null,
1597            "provider": { "enabled": true }
1598        }))
1599        .expect("answers");
1600
1601        inject_setup_public_base_url(&mut answers, "https://new.trycloudflare.com");
1602
1603        // Scalar, array, and null are skipped entirely.
1604        assert_eq!(answers["scalar"], json!("not-an-object"));
1605        assert_eq!(answers["array"], json!([1, 2, 3]));
1606        assert_eq!(answers["null"], json!(null));
1607        // Enabled object provider gets injected.
1608        assert_eq!(
1609            answers["provider"]["public_base_url"],
1610            json!("https://new.trycloudflare.com")
1611        );
1612    }
1613
1614    #[test]
1615    fn inject_preserves_ngrok_stale_url() {
1616        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1617            "messaging-telegram": {
1618                "public_base_url": "https://old.ngrok-free.app"
1619            }
1620        }))
1621        .expect("answers");
1622
1623        inject_setup_public_base_url(&mut answers, "https://new.ngrok-free.app");
1624
1625        assert_eq!(
1626            answers["messaging-telegram"]["public_base_url"],
1627            json!("https://new.ngrok-free.app")
1628        );
1629    }
1630
1631    #[test]
1632    fn inject_whitespace_only_url_gets_replaced() {
1633        let mut answers = serde_json::from_value::<JsonMap<String, Value>>(json!({
1634            "messaging-slack": {
1635                "public_base_url": "   "
1636            }
1637        }))
1638        .expect("answers");
1639
1640        inject_setup_public_base_url(&mut answers, "https://demo.trycloudflare.com");
1641
1642        assert_eq!(
1643            answers["messaging-slack"]["public_base_url"],
1644            json!("https://demo.trycloudflare.com")
1645        );
1646    }
1647
1648    // ---- detects_ephemeral_tunnel_urls (original test preserved) ----
1649
1650    #[test]
1651    fn detects_ephemeral_tunnel_urls() {
1652        assert!(is_ephemeral_tunnel_url("https://demo.trycloudflare.com"));
1653        assert!(is_ephemeral_tunnel_url("https://demo.ngrok-free.app"));
1654        assert!(is_ephemeral_tunnel_url("https://demo.ngrok.io"));
1655        assert!(!is_ephemeral_tunnel_url("https://runtime.example.com"));
1656    }
1657
1658    // ---- platform_executable_name ----
1659
1660    #[test]
1661    fn platform_executable_name_returns_name() {
1662        let name = platform_executable_name("cloudflared");
1663        if cfg!(windows) {
1664            assert_eq!(name, "cloudflared.exe");
1665        } else {
1666            assert_eq!(name, "cloudflared");
1667        }
1668    }
1669
1670    #[test]
1671    fn platform_executable_name_ngrok() {
1672        let name = platform_executable_name("ngrok");
1673        if cfg!(windows) {
1674            assert_eq!(name, "ngrok.exe");
1675        } else {
1676            assert_eq!(name, "ngrok");
1677        }
1678    }
1679
1680    // ---- executable_exists ----
1681
1682    #[test]
1683    fn executable_exists_nonexistent_path() {
1684        assert!(!executable_exists(Path::new("/nonexistent/path/to/binary")));
1685    }
1686
1687    #[test]
1688    fn executable_exists_regular_file_without_exec() {
1689        let dir = tempfile::tempdir().expect("tempdir");
1690        let file_path = dir.path().join("not-executable");
1691        std::fs::write(&file_path, b"data").expect("write");
1692        #[cfg(unix)]
1693        {
1694            use std::os::unix::fs::PermissionsExt;
1695            std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o644))
1696                .expect("chmod");
1697        }
1698        assert!(!executable_exists(&file_path));
1699    }
1700
1701    #[test]
1702    fn executable_exists_with_exec_bit() {
1703        let dir = tempfile::tempdir().expect("tempdir");
1704        let file_path = dir.path().join("executable");
1705        std::fs::write(&file_path, b"#!/bin/sh\n").expect("write");
1706        #[cfg(unix)]
1707        {
1708            use std::os::unix::fs::PermissionsExt;
1709            std::fs::set_permissions(&file_path, std::fs::Permissions::from_mode(0o755))
1710                .expect("chmod");
1711        }
1712        assert!(executable_exists(&file_path));
1713    }
1714
1715    #[test]
1716    fn executable_exists_directory_is_false() {
1717        let dir = tempfile::tempdir().expect("tempdir");
1718        assert!(!executable_exists(dir.path()));
1719    }
1720
1721    // ---- managed_tunnel_binary_path ----
1722
1723    #[test]
1724    fn managed_binary_path_contains_binary_name() {
1725        // Regardless of env, the filename portion should contain the binary name.
1726        let path = managed_tunnel_binary_path("cloudflared");
1727        let file_name = path.file_name().expect("has file name");
1728        assert!(
1729            file_name.to_str().expect("utf8").contains("cloudflared"),
1730            "expected cloudflared in path, got {path:?}"
1731        );
1732    }
1733
1734    #[test]
1735    fn managed_binary_path_ngrok() {
1736        let path = managed_tunnel_binary_path("ngrok");
1737        let file_name = path.file_name().expect("has file name");
1738        assert!(
1739            file_name.to_str().expect("utf8").contains("ngrok"),
1740            "expected ngrok in path, got {path:?}"
1741        );
1742    }
1743
1744    // ---- cloudflared_release_asset ----
1745
1746    #[test]
1747    fn cloudflared_release_asset_returns_some_on_supported_platform() {
1748        let asset = cloudflared_release_asset();
1749        // We're running on a supported CI/dev platform (linux x86_64 or aarch64).
1750        match (std::env::consts::OS, std::env::consts::ARCH) {
1751            ("linux", "x86_64") => assert_eq!(asset, Some("cloudflared-linux-amd64")),
1752            ("linux", "aarch64") => assert_eq!(asset, Some("cloudflared-linux-arm64")),
1753            ("macos", "aarch64") => {
1754                assert_eq!(asset, Some("cloudflared-darwin-arm64.tgz"))
1755            }
1756            ("macos", "x86_64") => {
1757                assert_eq!(asset, Some("cloudflared-darwin-amd64.tgz"))
1758            }
1759            _ => {
1760                // On unsupported platforms, Some or None is fine.
1761            }
1762        }
1763    }
1764
1765    // ---- resolve_tunnel_binary error branch ----
1766
1767    #[test]
1768    fn resolve_tunnel_binary_unsupported_mode() {
1769        let err = resolve_tunnel_binary("unknown").unwrap_err();
1770        assert!(
1771            err.to_string().contains("unsupported"),
1772            "expected 'unsupported' in error: {err}"
1773        );
1774    }
1775
1776    // ---- resolve_path_binary ----
1777
1778    #[test]
1779    fn resolve_path_binary_finds_existing() {
1780        // "sh" should exist on PATH on any POSIX system.
1781        if cfg!(unix) {
1782            let result = resolve_path_binary("sh");
1783            assert!(result.is_some(), "sh should be found on PATH");
1784        }
1785    }
1786
1787    #[test]
1788    fn resolve_path_binary_missing_returns_none() {
1789        let result = resolve_path_binary("nonexistent-binary-xyz-12345");
1790        assert!(result.is_none());
1791    }
1792
1793    // ---- start_setup_tunnel error branch ----
1794
1795    #[test]
1796    fn start_setup_tunnel_unsupported_mode() {
1797        let result = start_setup_tunnel("unknown", "http://127.0.0.1:8080", None);
1798        assert!(result.is_err());
1799        let err = result.err().expect("should be Err");
1800        assert!(
1801            err.to_string().contains("unsupported"),
1802            "expected 'unsupported' in error: {err}"
1803        );
1804    }
1805
1806    // ---- SetupTunnel struct / is_running with no child ----
1807
1808    #[test]
1809    fn setup_tunnel_no_child_reports_running() {
1810        // A reused shared-record tunnel has no child process.
1811        let mut tunnel = SetupTunnel {
1812            mode: "cloudflared".to_string(),
1813            local_base_url: "http://127.0.0.1:8080".to_string(),
1814            public_base_url: "https://demo.trycloudflare.com".to_string(),
1815            child: None,
1816            kill_on_drop: false,
1817        };
1818        // No child: is_running returns true (liveness is external).
1819        assert!(tunnel.is_running());
1820    }
1821
1822    #[test]
1823    fn setup_tunnel_drop_no_child_no_kill() {
1824        // Dropping with no child and kill_on_drop false should not panic.
1825        let tunnel = SetupTunnel {
1826            mode: "cloudflared".to_string(),
1827            local_base_url: "http://127.0.0.1:8080".to_string(),
1828            public_base_url: "https://demo.trycloudflare.com".to_string(),
1829            child: None,
1830            kill_on_drop: false,
1831        };
1832        drop(tunnel);
1833    }
1834
1835    #[test]
1836    fn setup_tunnel_drop_with_kill_on_drop_false() {
1837        // Even with a finished child, kill_on_drop false means Drop does nothing.
1838        let child = std::process::Command::new("true")
1839            .spawn()
1840            .expect("spawn true");
1841        let tunnel = SetupTunnel {
1842            mode: "ngrok".to_string(),
1843            local_base_url: "http://127.0.0.1:9090".to_string(),
1844            public_base_url: "https://demo.ngrok-free.app".to_string(),
1845            child: Some(child),
1846            kill_on_drop: false,
1847        };
1848        drop(tunnel);
1849    }
1850
1851    #[test]
1852    fn setup_tunnel_drop_with_kill_on_drop_true() {
1853        // With kill_on_drop true, Drop kills and waits.
1854        let child = std::process::Command::new("sleep")
1855            .arg("60")
1856            .spawn()
1857            .expect("spawn sleep");
1858        let tunnel = SetupTunnel {
1859            mode: "ngrok".to_string(),
1860            local_base_url: "http://127.0.0.1:9091".to_string(),
1861            public_base_url: "https://demo.ngrok-free.app".to_string(),
1862            child: Some(child),
1863            kill_on_drop: true,
1864        };
1865        drop(tunnel);
1866    }
1867
1868    #[test]
1869    fn setup_tunnel_is_running_with_finished_child() {
1870        let child = std::process::Command::new("true")
1871            .spawn()
1872            .expect("spawn true");
1873        let mut tunnel = SetupTunnel {
1874            mode: "ngrok".to_string(),
1875            local_base_url: "http://127.0.0.1:9092".to_string(),
1876            public_base_url: "https://demo.ngrok-free.app".to_string(),
1877            child: Some(child),
1878            kill_on_drop: false,
1879        };
1880        // Wait for the child to finish.
1881        std::thread::sleep(std::time::Duration::from_millis(100));
1882        assert!(!tunnel.is_running());
1883    }
1884
1885    #[test]
1886    fn setup_tunnel_is_running_with_alive_child() {
1887        let child = std::process::Command::new("sleep")
1888            .arg("60")
1889            .spawn()
1890            .expect("spawn sleep");
1891        let mut tunnel = SetupTunnel {
1892            mode: "ngrok".to_string(),
1893            local_base_url: "http://127.0.0.1:9093".to_string(),
1894            public_base_url: "https://demo.ngrok-free.app".to_string(),
1895            child: Some(child),
1896            kill_on_drop: true,
1897        };
1898        assert!(tunnel.is_running());
1899        // Clean up via kill_on_drop.
1900    }
1901
1902    // ---- finalize_installed_binary ----
1903
1904    #[test]
1905    fn finalize_installed_binary_renames_and_sets_permissions() {
1906        let dir = tempfile::tempdir().expect("tempdir");
1907        let temp = dir.path().join("temp-binary");
1908        let target = dir.path().join("final-binary");
1909        std::fs::write(&temp, b"fake binary content").expect("write");
1910
1911        finalize_installed_binary(&temp, &target).expect("finalize");
1912
1913        assert!(!temp.exists(), "temp file should be renamed away");
1914        assert!(target.exists(), "target should exist");
1915        #[cfg(unix)]
1916        {
1917            use std::os::unix::fs::PermissionsExt;
1918            let mode = std::fs::metadata(&target)
1919                .expect("meta")
1920                .permissions()
1921                .mode();
1922            assert_ne!(mode & 0o111, 0, "target should be executable");
1923        }
1924    }
1925
1926    // ---- spawn_tunnel_log_reader ----
1927
1928    #[test]
1929    fn spawn_log_reader_sends_lines() {
1930        let input = b"line one\nline two\nline three\n";
1931        let cursor = std::io::Cursor::new(input.to_vec());
1932        let (tx, rx) = std::sync::mpsc::channel::<String>();
1933        spawn_tunnel_log_reader(cursor, tx);
1934
1935        let mut lines = Vec::new();
1936        while let Ok(line) = rx.recv_timeout(std::time::Duration::from_secs(1)) {
1937            lines.push(line);
1938        }
1939        assert_eq!(lines, vec!["line one", "line two", "line three"]);
1940    }
1941
1942    #[test]
1943    fn spawn_log_reader_empty_input() {
1944        let cursor = std::io::Cursor::new(Vec::new());
1945        let (tx, rx) = std::sync::mpsc::channel::<String>();
1946        spawn_tunnel_log_reader(cursor, tx);
1947
1948        // Should produce no lines and disconnect promptly.
1949        let result = rx.recv_timeout(std::time::Duration::from_millis(500));
1950        assert!(result.is_err());
1951    }
1952}