shep 0.18.0

The shep binary: a process manager that keeps a flock of long-running processes alive on macOS, Linux and Windows, with logs, watch and cron restarts, and webhook alerts
Documentation
//! `shep dogs grant` and `shep dogs revoke`: widening or tightening one
//! permission of a dog that has already been through its first question.
//!
//! Only a permission the dog's manifest declares can be granted. A dog with
//! no `dog_grants` entry is not touched: it is either undeclared, with full
//! access that one name cannot be carved out of, or it was never asked, and
//! `shep enable` asks.

use std::str::FromStr as _;

use shep_client::Client;
use shep_core::paths::ShepPaths;
use shep_core::permissions::{Grants, Permission};
use shep_core::protocol::{DogSource, Request, Response};

use crate::commands::rpc::{client_error, unexpected_response};
use crate::commands::shep_toml::{GrantRecord, ShepToml, ShepTomlError};
use crate::exit::ExitCode;
use crate::output::{DogActionStatus, Streams};

use super::enable::fail_enable_unknown_dog;
use super::grants::warn_if_unenforced;
use super::upgrade::upgrade_one;
use super::vet::{ManifestRead, VERSION_BUDGET, fail_adopt, read_manifest};
use super::{connect_or_absent, dog_source, emit_dog_action, fail_config};

/// Which of the two verbs ran.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Change {
    /// `shep dogs grant`
    Grant,
    /// `shep dogs revoke`
    Revoke,
}

impl Change {
    /// The verb as the operator types it.
    const fn verb(self) -> &'static str {
        match self {
            Self::Grant => "grant",
            Self::Revoke => "revoke",
        }
    }
}

/// What the change left in `shep.toml`.
#[derive(Debug)]
struct Applied {
    /// The dog as the next enable would send it, carrying the grants now
    /// recorded.
    source: DogSource,
    /// Whether the dog is in `enabled_dogs`.
    enabled: bool,
    /// What the dog's build asks for that is still not granted.
    waiting: Vec<String>,
}

/// [`change`]'s own failure, so its `try_edit` closure can refuse from
/// inside the lock.
///
/// `Debug` is derived: [`Self::Config`] forwards to [`ShepTomlError`]'s own
/// redacted `Debug`.
#[derive(Debug)]
enum Refusal {
    /// The read-modify-write underneath the closure failed.
    Config(ShepTomlError),
    /// The dog has no `dog_grants` entry.
    Unrecorded,
}

impl From<ShepTomlError> for Refusal {
    fn from(err: ShepTomlError) -> Self {
        Self::Config(err)
    }
}

/// `shep dogs grant|revoke <name> <permission>`: records the change in
/// `shep.toml`.
pub(crate) async fn change(
    streams: &mut Streams<'_>,
    paths: &ShepPaths,
    change: Change,
    name: &str,
    permission: &str,
) -> ExitCode {
    let permission = match Permission::from_str(permission) {
        Ok(permission) => permission.to_string(),
        Err(err) => return streams.fail(ExitCode::Usage, &err.to_string()),
    };
    let cfg = match ShepToml::read_only(&paths.daemon_config) {
        Ok(cfg) => cfg,
        Err(err) => return fail_config(streams, &err),
    };
    let binary = cfg.adopted_dog_path(name);
    if binary.is_none() && !crate::dog::BUILT_IN_DOGS.contains(&name) {
        return fail_enable_unknown_dog(streams, name, &cfg.adopted_dog_names());
    }
    if change == Change::Grant {
        let manifest = match &binary {
            Some(binary) => match read_manifest(binary, &paths.home, name, VERSION_BUDGET) {
                Ok(ManifestRead::Unreadable) => {
                    return streams.fail(ExitCode::Usage, &unreadable(name));
                }
                Ok(read) => read.manifest().cloned(),
                Err(refusal) => return fail_adopt(streams, binary, &refusal),
            },
            None => crate::dog::builtin_manifest(name),
        };
        let declared = manifest.map_or_else(Vec::new, |manifest| {
            manifest
                .permissions
                .into_iter()
                .map(|permission| permission.name)
                .collect()
        });
        if !declared.contains(&permission) {
            return streams.fail(ExitCode::Usage, &not_declared(name, &permission, &declared));
        }
    }
    // Before the edit, so a permission the dog's build has just come to ask
    // for is already waiting when the operator grants it. Only for a dog that
    // has a record: writing one here would mark a whole declared list
    // reviewed on the strength of one permission named.
    if matches!(cfg.grant_record(name), Ok(Some(_)))
        && let Err(err) = upgrade_one(paths, name)
    {
        return fail_config(streams, &err);
    }
    let applied = ShepToml::try_edit(&paths.daemon_config, |cfg| {
        let Some(held) = cfg.grant_record(name)? else {
            return Err(Refusal::Unrecorded);
        };
        let without = |grants: &Grants| {
            Grants::from_names(
                grants
                    .names()
                    .filter(|kept| *kept != permission)
                    .map(str::to_owned),
            )
        };
        let names = |grants: &Grants| grants.names().any(|held| held == permission);
        let now = match change {
            // Naming a permission is the operator looking at the dog, so the
            // not-reviewed mark goes with it. What else waits stays waiting.
            Change::Grant => GrantRecord {
                granted: Grants::from_names(
                    held.granted
                        .names()
                        .map(str::to_owned)
                        .chain([permission.clone()]),
                ),
                waiting: without(&held.waiting),
                revoked: without(&held.revoked),
                reviewed: true,
            },
            // Nothing to take away: neither held nor waiting.
            Change::Revoke if !names(&held.granted) && !names(&held.waiting) => held.clone(),
            // Remembered as revoked, so a build that still declares it is not
            // read as asking for something new.
            Change::Revoke => GrantRecord {
                granted: without(&held.granted),
                waiting: without(&held.waiting),
                revoked: Grants::from_names(
                    held.revoked
                        .names()
                        .map(str::to_owned)
                        .chain([permission.clone()]),
                ),
                reviewed: held.reviewed,
            },
        };
        if now != held {
            cfg.set_grant_record(name, &now)?;
        }
        Ok(Applied {
            source: dog_source(cfg, name, Some(now.granted.clone())),
            enabled: cfg.enabled_dog_names().iter().any(|dog| dog == name),
            waiting: now.waiting.names().map(str::to_owned).collect(),
        })
    });
    let applied = match applied {
        Ok(applied) => applied,
        Err(Refusal::Config(err)) => return fail_config(streams, &err),
        Err(Refusal::Unrecorded) => return streams.fail(ExitCode::Usage, &unrecorded(name)),
    };
    // An enable would start a dog the operator switched off; a disabled
    // dog picks the grants up from `shep.toml` when it is enabled.
    let client = if applied.enabled {
        match connect_or_absent(paths, streams).await {
            Ok(client) => client,
            Err(code) => return code,
        }
    } else {
        None
    };
    if !applied.waiting.is_empty() {
        streams.aside(
            "dog_grants_waiting",
            &format!(
                "{name} still waits on {}: `shep dogs grant {name} <permission>` or `shep enable \
                 {name}` approves",
                applied.waiting.join(", ")
            ),
        );
    }
    reach_shepherd(streams, change, name, &applied, client.as_ref()).await
}

/// Tells a running shepherd what `name` now holds, the way a re-enable does:
/// the shepherd records the grants a source carries before it finds the dog
/// already running, so the dog's next request is judged by them.
async fn reach_shepherd(
    streams: &mut Streams<'_>,
    change: Change,
    name: &str,
    applied: &Applied,
    client: Option<&Client>,
) -> ExitCode {
    let report = |streams: &mut Streams<'_>, status| {
        emit_dog_action(streams, change.verb(), name, applied.source.clone(), status)
    };
    if !applied.enabled {
        return report(
            streams,
            DogActionStatus::NoShepherd("recorded; the dog is disabled and gets it when enabled"),
        );
    }
    let Some(client) = client else {
        return report(
            streams,
            DogActionStatus::NoShepherd(
                "recorded; takes effect when the next shepherd starts the dog",
            ),
        );
    };
    let request = Request::EnableDog {
        name: name.to_string(),
        source: applied.source.clone(),
    };
    match client.request(request).await {
        Ok(Response::DogStarted(_)) => {
            warn_if_unenforced(streams, name, &applied.source, client.daemon().protocol);
            report(
                streams,
                DogActionStatus::Acted("applied to the running dog"),
            )
        }
        Ok(_unrecognised) => unexpected_response(streams),
        Err(err) => client_error(streams, &err),
    }
}

/// The refusal of a grant the manifest never asked for.
fn not_declared(name: &str, permission: &str, declared: &[String]) -> String {
    if declared.is_empty() {
        return format!(
            "{name} declares no permissions, so `{permission}` cannot be granted to it"
        );
    }
    let mut sorted = declared.to_vec();
    sorted.sort();
    format!(
        "{name} did not declare `{permission}`, so it cannot be granted. It declares: {}",
        sorted.join(", ")
    )
}

/// The refusal of a grant when the record of what the dog declares is lost.
fn unreadable(name: &str) -> String {
    format!(
        "{name}'s manifest record could not be read, so what it declares is unknown. \
         Nothing was changed; the cache under `manifests/` in the shep home is safe to delete"
    )
}

/// The refusal of a dog with no recorded grants.
fn unrecorded(name: &str) -> String {
    format!(
        "{name} has no recorded grants: it either declares nothing and runs with full access, \
         or was never asked. Nothing was changed; `shep enable {name}` lists its \
         permissions and asks"
    )
}