wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! The command line's side of the control API.
//!
//! Blocking HTTP, one request at a time. A command asks the daemon a question
//! and then does its work, so there is nothing to pipeline and no reason to
//! hold a connection across the minutes in which the operator may stop it.

use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;

use super::protocol::{route, ApiError, Info, ResolveRequest, WorktreeInfo};
use super::machine::MachineRendezvous;

/// How long a single exchange may take.
///
/// The daemon answers from memory, so this is not a work budget — it is the
/// bound on "the port answered but nothing is serving the API", which
/// otherwise hangs a command line forever.
const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(10);

/// Resolving a worktree is the exception. The first command run in a
/// worktree is what opens it, and opening means opening its database
/// and running every migration it is behind on — real work, on a repository
/// that may be large, not an answer from memory. Bounded all the same, so a
/// daemon that has genuinely wedged still gives the command line an answer.
const REGISTER_TIMEOUT: Duration = Duration::from_secs(120);

/// A client for this machine's daemon.
pub struct Client {
    base: String,
    /// Which worktree this client's requests are about.
    ///
    /// One daemon serves the machine, so every worktree-scoped request names
    /// one. Resolved once, from the directory the command was run in, by
    /// asking the daemon — because the daemon is the thing that knows which
    /// worktrees it has open.
    worktree: Mutex<Option<WorktreeInfo>>,
    /// This daemon's control secret, as its 0600 rendezvous file published it.
    control_token: String,
    http: reqwest::blocking::Client,
}

impl std::fmt::Debug for Client {
    /// Never prints the token.
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("Client")
            .field("base", &self.base)
            .field("worktree", &self.worktree_info().map(|w| w.id))
            .finish_non_exhaustive()
    }
}

impl Clone for Client {
    fn clone(&self) -> Self {
        Self {
            base: self.base.clone(),
            worktree: Mutex::new(self.worktree_info()),
            control_token: self.control_token.clone(),
            http: self.http.clone(),
        }
    }
}

impl Client {
    /// Point a client at what a rendezvous file describes.
    ///
    /// Does not connect: whether the daemon is alive is answered by asking it
    /// something, which [`Client::info`] does, rather than by a separate
    /// liveness call that could disagree with the next request.
    pub fn from_rendezvous(rendezvous: &MachineRendezvous) -> Result<Self, String> {
        let http = reqwest::blocking::Client::builder()
            .timeout(EXCHANGE_TIMEOUT)
            .connect_timeout(EXCHANGE_TIMEOUT)
            // The daemon is on this machine. A proxy configured for the
            // operator's browsing would send the control token somewhere else.
            .no_proxy()
            .build()
            .map_err(|e| format!("cannot build the daemon client: {e}"))?;
        Ok(Self {
            base: format!(
                "http://{}",
                std::net::SocketAddr::new(dialable(rendezvous.http_address), rendezvous.http_port)
            ),
            worktree: Mutex::new(None),
            // Carried from the rendezvous file, which is 0600: having read it
            // is what says this caller is the account the daemon runs as.
            control_token: rendezvous.control_token.clone(),
            http,
        })
    }

    /// Tell this client which worktree its requests are about, by naming the
    /// directory a command is standing in.
    ///
    /// The daemon resolves it — CWD → super root → worktree — and opens it if
    /// it has not. A client that has not done this can still ask machine-wide
    /// questions; anything worktree-scoped refuses rather than guessing which
    /// one was meant.
    ///
    /// The answer is checked, not trusted: the worktree it names must be the
    /// one `cwd` is inside. A daemon that answered with another — which is
    /// what keying worktrees by their repository once did, handing every
    /// worktree of a project the first one's database — is refused by name.
    pub fn resolve_worktree(&self, cwd: &Path) -> Result<WorktreeInfo, String> {
        let body = ResolveRequest {
            cwd: cwd.display().to_string(),
        };
        let request = self
            .http
            .post(self.url(route::RESOLVE))
            .timeout(REGISTER_TIMEOUT)
            .json(&body);
        let info: WorktreeInfo = self.send(request, route::RESOLVE)?;
        check_resolution(cwd, &info)?;
        if let Ok(mut held) = self.worktree.lock() {
            *held = Some(info.clone());
        }
        Ok(info)
    }

    /// The worktree this client is about, if it has been resolved.
    pub fn worktree_info(&self) -> Option<WorktreeInfo> {
        self.worktree.lock().ok().and_then(|held| held.clone())
    }

    /// The worktree this client is about, or a refusal naming what is missing.
    fn worktree_id(&self) -> Result<String, String> {
        self.worktree_info().map(|info| info.id).ok_or_else(|| {
            "this client has not been told which worktree it is for; \
             call resolve_worktree first"
                .to_string()
        })
    }

    /// The worktree's root on this machine, once resolved.
    pub fn worktree_root(&self) -> Option<PathBuf> {
        self.worktree_info().map(|info| PathBuf::from(info.root))
    }

    /// Where the workbench is, for printing.
    pub fn workbench_url(&self) -> &str {
        &self.base
    }

    /// What this daemon is.
    pub fn info(&self) -> Result<Info, String> {
        let request = self.http.get(self.url(route::INFO));
        self.send(request, route::INFO)
    }

    /// Ask the daemon to stop.
    ///
    /// It answers before it exits, so a caller that gets `Ok` knows the
    /// request was accepted — not that the process is already gone. The
    /// control token is what says "the operator of this machine", which is the
    /// only thing stopping a daemon needs to establish.
    pub fn shutdown(&self) -> Result<(), String> {
        let mut request = self
            .http
            .post(self.url(route::SHUTDOWN))
            .json(&serde_json::json!({}));
        if !self.control_token.is_empty() {
            request = request.header(route::CONTROL_HEADER, &self.control_token);
        }
        self.send::<serde_json::Value>(request, route::SHUTDOWN)
            .map(|_| ())
    }

    /// Ask the daemon's control API a question only this machine's operator
    /// may ask, as JSON: the control token goes with it.
    pub fn operator_get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, String> {
        let request = self
            .http
            .get(self.url(path))
            .header(route::CONTROL_HEADER, &self.control_token);
        self.send(request, path)
    }

    /// Ask the daemon's workbench API a question, as JSON.
    ///
    /// The same server the page uses, over loopback, through the same client
    /// that carries the control API — so a command and a button really are one
    /// operation rather than two implementations that agree.
    pub fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, String> {
        let request = self.http.get(self.url(path));
        self.send(request, path)
    }

    /// Ask a question about THIS client's worktree.
    ///
    /// The worktree is a path segment, because the daemon is a machine's
    /// rather than a worktree's — so every worktree-scoped call has to say
    /// which one, and building that prefix by hand at each call site is how
    /// one of them ends up asking the wrong worktree. Refuses rather than
    /// guesses when nothing has resolved one.
    pub fn worktree_post<T: serde::de::DeserializeOwned>(
        &self,
        path: &str,
        body: &impl serde::Serialize,
    ) -> Result<T, String> {
        let id = self.worktree_id()?;
        self.post_json(&format!("/api/v1/worktrees/{id}{path}"), body)
    }

    /// The same, for a question that changes nothing.
    pub fn worktree_get<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, String> {
        let id = self.worktree_id()?;
        self.get_json(&format!("/api/v1/worktrees/{id}{path}"))
    }

    pub fn post_json<T: serde::de::DeserializeOwned>(
        &self,
        path: &str,
        body: &impl serde::Serialize,
    ) -> Result<T, String> {
        let request = self.http.post(self.url(path)).json(body);
        self.send(request, path)
    }

    pub fn delete_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, String> {
        let request = self.http.delete(self.url(path));
        self.send(request, path)
    }

    /// A removal that has to say what it is removing from.
    ///
    /// Some of the workbench's removals take a body — a card is removed from a
    /// board the request names twice, in its path and in its body, so that one
    /// of the two being wrong is refused rather than acted on.
    pub fn delete_json_with<T: serde::de::DeserializeOwned>(
        &self,
        path: &str,
        body: &impl serde::Serialize,
    ) -> Result<T, String> {
        let request = self.http.delete(self.url(path)).json(body);
        self.send(request, path)
    }

    fn url(&self, path: &str) -> String {
        format!("{}{path}", self.base)
    }

    fn send<T: serde::de::DeserializeOwned>(
        &self,
        request: reqwest::blocking::RequestBuilder,
        path: &str,
    ) -> Result<T, String> {
        let response = request
            .send()
            .map_err(|e| format!("the daemon is not answering at {}{path}: {e}", self.base))?;
        let status = response.status();
        let body = response
            .text()
            .map_err(|e| format!("the daemon's answer to {path} could not be read: {e}"))?;

        if !status.is_success() {
            // The daemon states its refusals in the body. A status code with
            // no body is a daemon that failed before it could say why, and the
            // code is all there is to report.
            let message = serde_json::from_str::<ApiError>(&body)
                .ok()
                .map(|api| api.error)
                .unwrap_or_else(|| body.trim().to_string());
            return Err(if message.is_empty() {
                format!("the daemon refused {path} with status {status}")
            } else {
                message
            });
        }

        serde_json::from_str(&body)
            .map_err(|e| format!("the daemon's answer to {path} was not one: {e}"))
    }
}

/// Refuse a resolution that names a worktree `cwd` is not inside.
///
/// Compared through the filesystem, because one directory has several
/// spellings — `/tmp` against `/private/tmp`, a symlinked home.
pub(crate) fn check_resolution(cwd: &Path, info: &WorktreeInfo) -> Result<(), String> {
    let root = PathBuf::from(&info.root);
    let inside = match (crate::platform::canonicalize(cwd), crate::platform::canonicalize(&root)) {
        (Ok(cwd), Ok(root)) => cwd.starts_with(&root),
        _ => cwd.starts_with(&root),
    };
    if inside {
        Ok(())
    } else {
        Err(format!(
            "the daemon answered {} with worktree {} at {}, which does not contain it; \
             restart it (`wsb daemon restart`) — it is serving one worktree's state to another",
            cwd.display(),
            info.id,
            info.root
        ))
    }
}

/// The address to dial for a daemon that is bound to `listening`.
///
/// `0.0.0.0` and `::` mean "every interface", which is not an address anything
/// connects TO — the daemon is reachable on loopback like everywhere else, so
/// that is where a command on the same machine goes. Any other address is one
/// the daemon chose specifically, and is the only one it answers on.
pub(crate) fn dialable(listening: std::net::IpAddr) -> std::net::IpAddr {
    if listening.is_unspecified() {
        match listening {
            std::net::IpAddr::V4(_) => std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
            std::net::IpAddr::V6(_) => std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST),
        }
    } else {
        listening
    }
}

#[cfg(test)]
#[path = "client_tests.rs"]
mod tests;