yog 0.0.76

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
//! **Enrollment's executor** (REMOTE §1.4 as amended, §4.2, §8; bl-f4e3) —
//! beside [`advertise`](super::advertise) and [`delete_exec`](super::delete_exec)
//! for their reason: everything else in the chokepoint routes, and these
//! *gate*.
//!
//! **The grade gate is already spent, one layer up, and this file adds none**
//! (REMOTE §4.2). A foot may say three gestures — `advertise`, `invocations`,
//! `complete` — and the set is *enumerated* in
//! [`Grade::admits`](crate::registry::Grade::admits) rather than subtracted
//! from the operator's roster, so an [`Action`](crate::boundary::Action) added
//! today is operator-only by construction: `answer_as` refuses it in band,
//! naming the grade, ahead of the dispatch and ahead of the auto-registration.
//! That is why nothing here asks what grade the caller is. A check written
//! again here would be a second authority for one fact, and the second one is
//! the one that drifts.
//!
//! **An in-world caller is admitted, and that is the act's own class.** The
//! `gestures/` inbox and `yog gesture` carry no certificate (§4.1) and are the
//! world's own residents — the operator, at a terminal, on the box that holds
//! the CA. Enrolling from there is `yog wire-certs WIRE_LEAF=…` reached through
//! the boundary, which is exactly what §3 asks for: one surface, every face.
//!
//! **Nothing here dials anything.** The material is answered, never delivered;
//! §1.4 stands because the new device performs no channel act — holding no
//! certificate, it could not open a connection at all — and the bytes reach it
//! out of channel, in the operator's hand.

use std::path::Path;

use crate::opslog::Origin;
use crate::registry::enroll::{Enrolled, Request};
use crate::registry::{self, Client};
use crate::wire::{material, provision, rendezvous};

use super::Deps;
use crate::boundary::reply::Reply;

/// The trail's word for this act (§4.2) — `["yog-step","enroll"]` at exit 0.
/// **The row names no material**: an ops line is `argv`, `cwd` and a status,
/// and the fact worth recording is that an enrollment happened here.
const STEP: &str = "enroll";

/// **What a name whose leaf already exists means** (bl-bd48) — the one question
/// asked before the act runs, in its own file at §12's budget.
mod stance;

/// Mint or adopt, read, shred, seat, log — each step refusing with its own
/// sentence.
///
/// The order is the fail-closed one. The identity is parsed first, so an
/// unusable name refuses before `openssl` runs; the address second, because
/// material a device cannot dial is not worth minting, and the rendezvous
/// hand-off with it (bl-9043); the mint third, being the only step that can fail for a reason outside yog; and the registration
/// last, its input already validated by the chokepoint's own resolution.
///
/// **The third step mints OR adopts** ([`stance`], bl-bd48): a name whose leaf
/// `wire-certs` already issued is registered rather than refused, because
/// registering is not issuing — it mints nothing, distrusts nothing and leaves
/// one certificate under one identity. That is what makes this door the repair
/// for the leaf `WIRE_LEAF` strands, which was reachable before only by hand or
/// by a rotation. The grade the reply carries is then the one the CA wrote into
/// the subject, read back off the certificate, rather than the word the gesture
/// asked with.
/// Neither half-landing is a hazard — a registration with no certificate grants
/// nothing and a certificate with no registration sees nothing — but a mint
/// whose material never reached the answer would leave a live key on disk, and
/// [`carry`] closes that by shredding whether or not the reads succeeded.
pub(super) fn enroll(deps: &Deps, ts: &str, request: &Request) -> Result<Reply, String> {
    let client = Client::parse(&request.name)?;
    let dir = material::dir(&deps.world);
    let address = dialable(&dir, request.address.as_deref())?;
    // Read before the mint, like the address: half a pairing refuses with
    // nothing issued (bl-9043).
    let rendezvous = rendezvous::material::read_dir(&dir)?
        .map(|pairing| pairing.handoff())
        .transpose()?;
    let grade = stance::mint_or_adopt(&dir, &deps.state_root, request)?;
    let (ca, cert, key) = carry(&dir, &request.name)?;
    registry::register(&deps.state_root, &client, &request.workspace).map_err(|e| e.to_string())?;
    crate::actions::verbs::log_step_done(
        &deps.state_root,
        ts,
        &dir,
        STEP,
        Origin::World,
        deps.caller.client.clone(),
    )
    .map_err(|e| e.to_string())?;
    Ok(Reply::Enrolled(Enrolled {
        grade,
        name: request.name.clone(),
        address,
        ca,
        cert,
        key,
        rendezvous,
    }))
}

/// The address a device will dial (§8), or the refusal naming the remedy.
///
/// `wire/address` is that fact's one home, so it is the one read — and a
/// **`:0` port refuses**. A boot that provisioned this box itself wrote
/// `127.0.0.1:0`, which is a *request* the listener answers with whatever was
/// free (bl-dc14): only the listener knows the number, and it is a different
/// number after the next boot. Putting that in a QR would mint a code that was
/// stale before it was scanned. The remedy is the operator's own statement of
/// intent, which is the act §8 has always named.
///
/// **The remedy is spelled the way it must be typed HERE** (bl-a6b7, amended
/// bl-98ef). Reaching this arm means the material was read, so a bare re-run
/// refuses ("already holds material …") — a box that can produce this sentence
/// is, by construction, a box the bare remedy turns away. What it is NOT any
/// more is a rotation: a stated host and port over standing material re-issues
/// the server leaf and writes the address, over the CA already here, distrusting
/// nothing ([`provision::state`](crate::wire::provision)). The sentence said
/// `FORCE=1` while that was the only spelling, and told an operator whose one
/// complaint was a `:0` to distrust every leaf they had carried anywhere. The
/// other half stands: the address is read at bind time, so the engine standing
/// on the `:0` listener keeps it until it is restarted, and the restart is
/// named beside the act.
///
/// It reads the **server** end because that is the end a client dials, and
/// reading it as material rather than as a file is what makes a
/// half-provisioned box say so in `material`'s own words.
fn dialable(dir: &Path, stated: Option<&str>) -> Result<String, String> {
    // **What the operator stated wins, and is judged by the same rule**
    // (bl-fec6). The device being enrolled is not this box, so the route it
    // reaches this engine by need not be the one this box wrote for itself —
    // an emulator's host alias, a LAN address, an overlay name. A stated
    // endpoint still has to BE one, so it goes through `endpoint` exactly as
    // the file's own does; what it is not judged against is the server leaf's
    // SAN, which this crate cannot read (§8.4 records that residual).
    if let Some(stated) = stated {
        return endpoint(stated.trim());
    }
    let address = material::read_dir(dir, material::Role::Server)?
        .ok_or_else(|| {
            format!(
                "{} holds no wire material: an enrollment issues a leaf under a trust root that \
                 already exists — run `{}` where the CA lives",
                dir.display(),
                material::REMEDY
            )
        })?
        .address;
    // **And the settings go BEFORE the verb because they are environment
    // readings** (`provision::verb::READS`, read at the process edge). This
    // sentence once spelled them after it, borrowed from the Makefile, where a
    // trailing `VAR=value` is a make variable the recipe passes on; on the
    // binary the same words are argv, which the verb does not read. It minted
    // the DEFAULT loopback endpoint and exited 0 — the wrong trust root,
    // reported as a success. `verb::stray` now refuses a trailing word outright,
    // so this order is checked rather than merely written (bl-a0dd).
    endpoint(&address)
}

/// One `host:port` a device can be handed, or the refusal naming its remedy —
/// the judgement both the file's address and a stated one pass through, so
/// there is one rule for what an envelope may carry.
///
/// A **`:0` port refuses**, whichever said it: it is a request the listener
/// answers in RAM, and its answer is a different number after the next boot.
fn endpoint(address: &str) -> Result<String, String> {
    match address.rsplit_once(':') {
        Some((host, port)) if !host.is_empty() && !port.is_empty() && port != "0" => {
            Ok(address.to_owned())
        }
        _ => Err(format!(
            "{address} names no endpoint a device can dial: it wants a host and a port, and a \
             `:0` is a request the listener answers in RAM whose answer changes at every boot. \
             State the engine's own — `WIRE_HOST=<host> WIRE_PORT=<port> yog {}`, which \
             re-issues the server leaf and writes the address over the CA already here and \
             distrusts nothing, then restart the engine — or state the route THIS device will \
             dial with `--at <host>:<port>`, which must be one the server leaf already answers \
             to",
            provision::verb::SUBCMD
        )),
    }
}

/// The three PEMs the device carries away — anchors, its certificate, its key —
/// with the key **shredded** before they are handed back, so this box retains
/// none of it. The shred is unconditional on the reads, which is what makes a
/// failed read leave no key behind either.
///
/// The **certificate stays**, deliberately. It is public material, and its
/// presence on disk is what makes a second enrollment under one name refuse
/// ([`provision::issue`]) — re-issuing distrusts nothing, so two live
/// certificates under one identity would be the result. Keeping it is the
/// guard; keeping the key would be the leak.
fn carry(dir: &Path, name: &str) -> Result<(String, String, String), String> {
    let path = dir.join(format!("{name}.key"));
    // Read, then shred, then judge the read: the two lines are in this order so
    // no failure between them can leave the key behind.
    let key = read(&path);
    shred(&path)?;
    let key = key?;
    Ok((
        read(&dir.join(material::ANCHORS))?,
        read(&dir.join(format!("{name}.pem")))?,
        key,
    ))
}

/// One PEM off disk, or the refusal naming the file it could not read.
fn read(path: &Path) -> Result<String, String> {
    std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
}

/// Remove the minted key, or refuse loudly. A key that could not be shredded is
/// a key still on the box, so it is an error and never a warning — and the
/// sentence names the file, because the remaining act is the operator's.
fn shred(key: &Path) -> Result<(), String> {
    std::fs::remove_file(key).map_err(|e| {
        format!(
            "{}: {e} — the leaf was minted and its key could not be shredded; remove it by hand \
             before that name is trusted",
            key.display()
        )
    })
}

#[cfg(test)]
mod tests;