wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! How a command line finds the machine's daemon.
//!
//! A file in the machine's state directory, holding the port, the pid and the
//! control token. Written by the daemon once its HTTP server is bound, removed
//! when it stops.
//!
//! # What it authorises
//!
//! Stopping the daemon, and nothing else: the control token proves the caller
//! is the account the daemon runs as, which is what being able to read a 0600
//! file establishes. It is minted at startup and dies with the process, so a
//! file left behind by a daemon that crashed authorises nothing.
//!
//! # Why a file rather than a fixed port
//!
//! There is one daemon per workspace, and an operator has several workspaces.
//! A fixed port would make the second one collide with the first, and a port
//! derived from the path would make two workspaces on two machines disagree
//! about a number that has to be right. The file is written by the process
//! that owns the port, so the answer comes from the thing that knows it.
//!
//! # A stale file is not an error
//!
//! A daemon that was killed leaves its file behind. That is an ordinary state
//! — the machine rebooted, the process was killed — and the caller's response
//! is to start a daemon, not to fail. What must never happen is treating a
//! stale file as a live daemon, so a file is only believed once the port it
//! names answers.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

/// The file's name inside the state directory.
const FILE: &str = "daemon.json";

/// What the daemon publishes about itself.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rendezvous {
    /// The wsb version the daemon was built from.
    pub version: String,
    pub pid: u32,
    /// The address the workbench is bound to.
    ///
    /// Loopback unless the daemon was told otherwise. Recorded because a
    /// command line has to reach it: bound to one specific interface, this
    /// daemon does not answer on 127.0.0.1 at all, and a client that assumed
    /// it did would report a running daemon as dead.
    pub http_address: std::net::IpAddr,
    /// The workbench, and the control API mounted beside it. One server, one
    /// port: the daemon serves HTTP already, and a second listener would be a
    /// second thing to bind, record and explain.
    pub http_port: u16,
    /// The workspace this daemon serves, absolute.
    pub workspace_root: PathBuf,
    /// Unix seconds, for reporting.
    pub started_at: u64,
}

impl Rendezvous {
    /// Where the file lives for a given state directory.
    pub fn path_in(state_dir: &Path) -> PathBuf {
        state_dir.join(FILE)
    }

    /// Read it, or `None` when there is none.
    ///
    /// A file that is there but is not a rendezvous is an error rather than a
    /// `None`: something else is writing to this path, and starting a second
    /// daemon over the top of it would compound whatever that is.
    pub fn read(state_dir: &Path) -> Result<Option<Self>, String> {
        let path = Self::path_in(state_dir);
        let bytes = match std::fs::read(&path) {
            Ok(bytes) => bytes,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(format!("cannot read {}: {e}", path.display())),
        };
        serde_json::from_slice(&bytes)
            .map(Some)
            .map_err(|e| format!("{} is not a daemon rendezvous file: {e}", path.display()))
    }

    /// Write it, readable only by this user.
    ///
    /// Private not because of what it holds — it holds nothing secret — but
    /// because the pid and port of a daemon that will unlock a store is not
    /// something to publish to every account on a shared machine. Written
    /// through a sibling and renamed, so a reader sees a complete file or no
    /// file.
    pub fn write(&self, state_dir: &Path) -> Result<(), String> {
        std::fs::create_dir_all(state_dir)
            .map_err(|e| format!("cannot create {}: {e}", state_dir.display()))?;
        let path = Self::path_in(state_dir);
        let staged = state_dir.join(format!(".{FILE}.{}", std::process::id()));
        let encoded = serde_json::to_vec_pretty(self)
            .map_err(|e| format!("cannot encode the daemon rendezvous: {e}"))?;

        let outcome = (|| -> Result<(), String> {
            create_private(&staged)?;
            std::fs::write(&staged, &encoded)
                .map_err(|e| format!("cannot write {}: {e}", staged.display()))?;
            std::fs::rename(&staged, &path)
                .map_err(|e| format!("cannot publish {}: {e}", path.display()))
        })();
        if outcome.is_err() {
            let _ = std::fs::remove_file(&staged);
        }
        outcome
    }

    /// Remove it. Absent is success: the caller wants it gone.
    pub fn remove(state_dir: &Path) -> Result<(), String> {
        let path = Self::path_in(state_dir);
        match std::fs::remove_file(&path) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(format!("cannot remove {}: {e}", path.display())),
        }
    }
}

/// Create an empty file only this user can read, failing if it exists.
///
/// Shared with the session cache next door, which holds a bearer token and
/// needs the same treatment.
///
/// `create_new` so a file planted by somebody else is not written into, and
/// the mode is part of the creation rather than a `set_permissions` after it.
#[cfg(unix)]
pub(super) fn create_private(path: &Path) -> Result<(), String> {
    use std::os::unix::fs::OpenOptionsExt;
    let _ = std::fs::remove_file(path);
    std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(path)
        .map(|_| ())
        .map_err(|e| format!("cannot create {}: {e}", path.display()))
}

/// Windows has no mode bits to set at creation. The file inherits the
/// directory's ACL, which for the machine's state directory in a user profile
/// is the user's.
#[cfg(windows)]
pub(super) fn create_private(path: &Path) -> Result<(), String> {
    let _ = std::fs::remove_file(path);
    std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
        .map(|_| ())
        .map_err(|e| format!("cannot create {}: {e}", path.display()))
}

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