use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use shep_client::{Client, RequestError};
use shep_core::dogs::manifest::Manifest;
use shep_core::paths::{ShepPaths, user_home};
use shep_core::protocol::{DogSource, EnvValue, Request, Response, RpcErrorCode};
use crate::cli::AdoptArgs;
use crate::commands::dog_migration::{
DefaultsOutcome, DogMigrationError, migrate_dog_sections, seed_dog_defaults,
};
use crate::commands::rpc::{client_error, unexpected_response};
use crate::commands::shep_toml::{GrantRecord, ShepToml, ShepTomlError};
use crate::dog_index::IndexedPermission;
use crate::exit::ExitCode;
use crate::output::{DogActionStatus, Streams};
use super::consent::{Input, Review, Verdict, consent_from, review};
use super::depends::warn_of_unmet_depends;
use super::grants::warn_if_unenforced;
use super::vet::{
DogSchema, fail_adopt, report_channel_ask, report_dog_version, vet_binary, warn_group_writable,
warn_of_verb_declaration, warn_unreadable_schema,
};
use super::{
DECLINED_STATUS, DISABLED_STATUS, NO_SHEPHERD_ENABLE_STATUS, NOT_ENABLED_STATUS,
connect_or_absent, emit_dog_action, fail_config, recorded_events,
};
pub async fn adopt(streams: &mut Streams<'_>, paths: &ShepPaths, args: &AdoptArgs) -> ExitCode {
adopt_asking(streams, paths, args, &mut Input::stdin()).await
}
#[derive(Debug, Clone, Copy)]
pub(super) struct Flow {
pub(super) verb: &'static str,
pub(super) enable: bool,
pub(super) restart: bool,
}
impl Flow {
pub(super) const ADOPT: Self = Self {
verb: "adopt",
enable: true,
restart: false,
};
}
pub(super) async fn adopt_asking(
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &AdoptArgs,
input: &mut Input,
) -> ExitCode {
adopt_with(streams, paths, args, input, Flow::ADOPT, None).await
}
pub(super) async fn adopt_with(
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &AdoptArgs,
input: &mut Input,
flow: Flow,
note: Option<&str>,
) -> ExitCode {
adopt_noting(
streams,
paths,
args,
input,
flow,
Note {
text: note,
listed: None,
},
&mut false,
)
.await
}
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct Note<'a> {
pub(super) text: Option<&'a str>,
pub(super) listed: Option<&'a [IndexedPermission]>,
}
impl Note<'_> {
pub(super) fn lines(self, manifest: Option<&Manifest>) -> Option<String> {
let unlisted = self
.listed
.zip(manifest)
.into_iter()
.flat_map(|(listed, manifest)| {
manifest
.permissions
.iter()
.filter(|declared| listed.iter().all(|row| row.name != declared.name))
.map(|declared| format!("the index did not list {}", declared.name))
});
let lines = self
.text
.map(str::to_owned)
.into_iter()
.chain(unlisted)
.collect::<Vec<_>>();
(!lines.is_empty()).then(|| lines.join("\n"))
}
}
pub(super) async fn adopt_noting(
streams: &mut Streams<'_>,
paths: &ShepPaths,
args: &AdoptArgs,
input: &mut Input,
flow: Flow,
note: Note<'_>,
said_no: &mut bool,
) -> ExitCode {
let home = user_home(&|key| std::env::var_os(key));
let path_var = std::env::var_os("PATH");
let candidate = resolve_adopt_path(&args.path, home.as_deref(), path_var.as_deref());
let name = match &args.name {
Some(name) => name.clone(),
None => default_dog_name(&candidate),
};
if collides_with_a_verb(&name) {
return fail_adopt_name_collision(streams, &name);
}
let vetted = match vet_binary(&candidate, &paths.home, &name) {
Ok(vetted) => vetted,
Err(refusal) => return fail_adopt(streams, &candidate, &refusal),
};
let path = vetted.path.clone();
for writable in &vetted.group_writable {
warn_group_writable(streams, writable);
}
if let Some(answer) = &vetted.answer {
report_dog_version(streams, &name, answer);
}
let channel = vetted.answer.as_ref().is_some_and(|answer| answer.channel);
if channel {
report_channel_ask(streams, &name);
}
if vetted.schema == DogSchema::Unreadable {
warn_unreadable_schema(streams, &name);
}
let reviewed = match review(vetted.manifest.as_ref()) {
Ok(reviewed) => reviewed,
Err(refusal) => return refusal.report(streams, &name, "adopted"),
};
let note = note.lines(vetted.manifest.as_ref());
let verdict = match ask_before_adopting(
streams,
paths,
&name,
&reviewed,
note.as_deref(),
args.yes,
input,
) {
Ok(verdict) => verdict,
Err(code) => return code,
};
let (adopted, grants, declined, held_back, env, events) =
match ShepToml::try_edit(&paths.daemon_config, |cfg| {
cfg.adopt_dog(&name, &path, channel)?;
let decided_meanwhile = cfg.granted(&name)?.is_some();
let mut declined = false;
match &verdict {
Some(_) if decided_meanwhile => follow_declared_events(cfg, &name, &reviewed)?,
Some(Verdict::Grant(Some(grants))) => cfg.set_grant_record(
&name,
&GrantRecord::reviewed(grants.clone()).with_events(reviewed.events()),
)?,
Some(Verdict::Declined) => {
cfg.disable_dog(&name);
declined = true;
}
Some(Verdict::Grant(None)) => {}
None => follow_declared_events(cfg, &name, &reviewed)?,
}
let held_back = !flow.enable && !declined;
if held_back {
cfg.disable_dog(&name);
}
Ok((
cfg.adopted_dog_names(),
cfg.granted(&name)?,
declined,
held_back,
cfg.adopted_dog_env(&name),
recorded_events(cfg, &name),
))
}) {
Ok(recorded) => recorded,
Err(err) => return fail_config(streams, &err),
};
*said_no = declined;
if let Some(defaults) = vetted
.manifest
.as_ref()
.and_then(|manifest| manifest.defaults.as_deref())
{
match migrate_dog_sections(paths) {
Ok(moved) if moved.is_empty() => {}
Ok(moved) => streams.aside(
"adopt",
&format!(
"moved dog config out of shep.toml and into dogs.toml: {}",
moved.join(", ")
),
),
Err(err) => return fail_dogs_config(streams, &err),
}
match seed_dog_defaults(paths, &name, defaults) {
Ok(DefaultsOutcome::Written) => report_defaults_written(streams, &name),
Ok(DefaultsOutcome::Kept | DefaultsOutcome::Empty) => {}
Ok(DefaultsOutcome::Unreadable) => warn_unreadable_defaults(streams, &name),
Err(err) => return fail_dogs_config(streams, &err),
}
}
if let Some(manifest) = &vetted.manifest {
warn_of_unmet_depends(streams, &name, manifest, &adopted);
warn_of_pane_without_channel(streams, &name, manifest);
warn_of_verb_declaration(streams, &name, manifest);
}
let client = match connect_or_absent(paths, streams).await {
Ok(client) => client,
Err(code) => return code,
};
if declined {
let source = DogSource::Adopted {
grants: None,
path: path.display().to_string(),
channel,
events: Vec::new(),
};
return declined_after_config(streams, flow.verb, &name, source, client.as_ref()).await;
}
if held_back {
let source = DogSource::Adopted {
grants,
path: path.display().to_string(),
channel,
events,
};
return held_back_after_config(streams, flow.verb, &name, source, client.as_ref()).await;
}
let source = DogSource::Adopted {
grants,
path: path.display().to_string(),
channel,
events,
};
adopt_after_config(
streams,
flow.verb,
&name,
source,
&env,
client.as_ref(),
flow.restart,
)
.await
}
pub(super) async fn declined_after_config(
streams: &mut Streams<'_>,
verb: &str,
name: &str,
source: DogSource,
client: Option<&Client>,
) -> ExitCode {
off_after_config(streams, verb, name, source, client, DECLINED_STATUS).await
}
async fn held_back_after_config(
streams: &mut Streams<'_>,
verb: &str,
name: &str,
source: DogSource,
client: Option<&Client>,
) -> ExitCode {
off_after_config(streams, verb, name, source, client, NOT_ENABLED_STATUS).await
}
async fn off_after_config(
streams: &mut Streams<'_>,
verb: &str,
name: &str,
source: DogSource,
client: Option<&Client>,
idle: DogActionStatus,
) -> ExitCode {
let Some(client) = client else {
return emit_dog_action(streams, verb, name, source, idle);
};
match client
.request(Request::DisableDog {
name: name.to_string(),
})
.await
{
Ok(Response::Deleted(_ids)) => {
emit_dog_action(streams, verb, name, source, DISABLED_STATUS)
}
Err(RequestError::Rpc(rpc)) if rpc.code == RpcErrorCode::NotFound => {
emit_dog_action(streams, verb, name, source, idle)
}
Ok(_unrecognised) => unexpected_response(streams),
Err(err) => client_error(streams, &err),
}
}
fn ask_before_adopting(
streams: &mut Streams<'_>,
paths: &ShepPaths,
name: &str,
reviewed: &Review,
note: Option<&str>,
yes: bool,
input: &mut Input,
) -> Result<Option<Verdict>, ExitCode> {
let cfg =
ShepToml::read_only(&paths.daemon_config).map_err(|err| fail_config(streams, &err))?;
if cfg
.granted(name)
.map_err(|err| fail_config(streams, &err))?
.is_some()
{
return Ok(None);
}
consent_from(streams, name, "adopt", reviewed, note, yes, input).map(Some)
}
fn follow_declared_events(
cfg: &mut ShepToml,
name: &str,
reviewed: &Review,
) -> Result<(), ShepTomlError> {
let events = reviewed.events();
match cfg.grant_record(name)? {
Some(record) if record.events != events => {
cfg.set_grant_record(name, &GrantRecord { events, ..record })
}
_ => Ok(()),
}
}
fn resolve_adopt_path(raw: &Path, home: Option<&Path>, path_var: Option<&OsStr>) -> PathBuf {
let not_absent = |path: &Path| !matches!(path.try_exists(), Ok(false));
if not_absent(raw) {
return raw.to_path_buf();
}
if let Some(expanded) = raw
.to_str()
.and_then(|value| expand_tilde_candidate(value, home))
&& not_absent(&expanded)
{
return expanded;
}
if let Some(found) = lookup_on_path(raw, path_var) {
return found;
}
raw.to_path_buf()
}
fn expand_tilde_candidate(value: &str, home: Option<&Path>) -> Option<PathBuf> {
if !value.starts_with('~') {
return None;
}
shep_core::config::expand_home_tilde(value, home)
.ok()
.map(PathBuf::from)
}
pub(super) fn lookup_on_path(name: &Path, path_var: Option<&OsStr>) -> Option<PathBuf> {
let is_bare = name
.parent()
.is_some_and(|parent| parent.as_os_str().is_empty());
if !is_bare {
return None;
}
let dirs = path_var?;
std::env::split_paths(dirs)
.flat_map(|dir| {
candidate_file_names(name)
.into_iter()
.map(move |file| dir.join(file))
})
.find(|candidate| {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(candidate).is_ok_and(|meta| {
#[cfg(unix)]
{
meta.is_file() && meta.permissions().mode() & 0o111 != 0
}
#[cfg(windows)]
{
meta.is_file()
}
})
})
}
fn candidate_file_names(name: &Path) -> Vec<std::ffi::OsString> {
#[cfg(unix)]
{
vec![name.as_os_str().to_os_string()]
}
#[cfg(windows)]
{
let mut names = vec![name.as_os_str().to_os_string()];
let pathext =
std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
for ext in pathext.split(';').map(str::trim).filter(|e| !e.is_empty()) {
let mut with_ext = name.as_os_str().to_os_string();
with_ext.push(ext);
names.push(with_ext);
}
names
}
}
fn default_dog_name(path: &Path) -> String {
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_else(|| path.to_str().unwrap_or("dog"));
stem.strip_prefix("shep-")
.filter(|rest| !rest.is_empty())
.unwrap_or(stem)
.to_string()
}
pub(crate) fn collides_with_a_verb(name: &str) -> bool {
use clap::CommandFactory as _;
let mut command = crate::cli::Cli::command();
command.build();
command
.get_subcommands()
.any(|sub| sub.get_name() == name || sub.get_all_aliases().any(|alias| alias == name))
}
fn fail_adopt_name_collision(streams: &mut Streams<'_>, name: &str) -> ExitCode {
let code = ExitCode::InvalidConfig;
let message = format!(
"`{name}` is already a shep verb or alias, so an adopted dog by that name could never \
be reached -- pick another name with --name"
);
streams.fail(code, &message)
}
const DOG_DEFAULTS_WRITTEN_NOTICE: &str = "dog_defaults_written";
fn report_defaults_written(streams: &mut Streams<'_>, name: &str) {
let message = format!(
"{name}'s manifest defaults are now its `[{name}]` section in dogs.toml, where \
you can edit them"
);
streams.aside(DOG_DEFAULTS_WRITTEN_NOTICE, &message);
}
const DOG_DEFAULTS_UNREADABLE_NOTICE: &str = "dog_defaults_unreadable";
fn warn_unreadable_defaults(streams: &mut Streams<'_>, name: &str) {
let message = format!(
"{name} shipped a manifest whose `defaults` is not valid TOML, so nothing was \
added to dogs.toml for it. The dog is adopted and runs normally; this is a bug to \
report to whoever wrote it"
);
streams.aside(DOG_DEFAULTS_UNREADABLE_NOTICE, &message);
}
const DOG_PANE_WITHOUT_CHANNEL_NOTICE: &str = "dog_pane_without_channel";
fn warn_of_pane_without_channel(streams: &mut Streams<'_>, name: &str, manifest: &Manifest) {
if !manifest.pane_ignored {
return;
}
let message = format!(
"{name}'s manifest asks for a pane, but the dog never asked for the shepherd channel to \
answer it on, so it has none"
);
streams.aside(DOG_PANE_WITHOUT_CHANNEL_NOTICE, &message);
}
fn fail_dogs_config(streams: &mut Streams<'_>, err: &DogMigrationError) -> ExitCode {
let code = match err {
DogMigrationError::Parse(_)
| DogMigrationError::WouldOverwrite { .. }
| DogMigrationError::SectionsUnreadable { .. } => ExitCode::InvalidConfig,
_ => ExitCode::Failure,
};
streams.fail(code, &err.to_string())
}
async fn adopt_after_config(
streams: &mut Streams<'_>,
verb: &str,
name: &str,
source: DogSource,
env: &BTreeMap<String, String>,
client: Option<&Client>,
restart: bool,
) -> ExitCode {
let Some(client) = client else {
return emit_dog_action(streams, verb, name, source, NO_SHEPHERD_ENABLE_STATUS);
};
if restart {
match client
.request(Request::DisableDog {
name: name.to_string(),
})
.await
{
Ok(_) => {}
Err(RequestError::Rpc(rpc)) if rpc.code == RpcErrorCode::NotFound => {}
Err(err) => return client_error(streams, &err),
}
}
let request = Request::EnableDog {
name: name.to_string(),
source: source.clone(),
env: env
.iter()
.map(|(key, value)| (key.clone(), EnvValue::from(value.clone())))
.collect(),
};
match client.request(request).await {
Ok(Response::DogStarted(info)) => {
warn_if_unenforced(streams, name, &source, client.daemon().protocol);
emit_dog_action(
streams,
verb,
name,
source,
DogActionStatus::Acted(info.status.into()),
)
}
Ok(_unrecognised) => unexpected_response(streams),
Err(err) => client_error(streams, &err),
}
}
#[cfg(all(test, unix))]
mod tests;