use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use shep_client::Client;
use shep_core::paths::{ShepPaths, user_home};
use shep_core::protocol::{DogSource, Request, Response};
use crate::cli::AdoptArgs;
use crate::commands::rpc::{client_error, unexpected_response};
use crate::commands::shep_toml::ShepToml;
use crate::exit::ExitCode;
use crate::output::Streams;
use super::vet::{
DogSchema, fail_adopt, report_channel_ask, report_dog_version, vet_binary, warn_group_writable,
warn_unreadable_schema,
};
use super::{NO_SHEPHERD_ENABLE_STATUS, connect_or_absent, emit_dog_action, fail_config};
pub async fn adopt(streams: &mut Streams<'_>, paths: &ShepPaths, args: &AdoptArgs) -> 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;
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);
}
if let Err(err) = ShepToml::try_edit(&paths.daemon_config, |cfg| {
cfg.adopt_dog(&name, &path, channel)
}) {
return fail_config(streams, &err);
}
let client = match connect_or_absent(paths, streams).await {
Ok(client) => client,
Err(code) => return code,
};
adopt_after_config(streams, &name, &path, channel, client.as_ref()).await
}
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)
}
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()
}
fn collides_with_a_verb(name: &str) -> bool {
use clap::CommandFactory as _;
crate::cli::Cli::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)
}
async fn adopt_after_config(
streams: &mut Streams<'_>,
name: &str,
path: &Path,
channel: bool,
client: Option<&Client>,
) -> ExitCode {
let source = DogSource::Adopted {
path: path.display().to_string(),
channel,
};
let Some(client) = client else {
return emit_dog_action(
streams,
"adopt",
name,
source,
NO_SHEPHERD_ENABLE_STATUS,
false,
);
};
let request = Request::EnableDog {
name: name.to_string(),
source: source.clone(),
};
match client.request(request).await {
Ok(Response::DogStarted(info)) => {
emit_dog_action(streams, "adopt", name, source, info.status, true)
}
Ok(_unrecognised) => unexpected_response(streams),
Err(err) => client_error(streams, &err),
}
}
#[cfg(all(test, unix))]
mod tests {
use shep_client::testing::fake_client_capturing_envelopes;
use super::*;
use crate::cli::Format;
use crate::commands::dogs::test_support::streams;
#[tokio::test]
async fn adopt_of_a_missing_binary_reports_the_refusal_on_stderr() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: dir.path().join("nope"),
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::InvalidConfig);
let text = String::from_utf8(err).unwrap();
assert!(
text.contains("no file exists at that path"),
"the refusal must reach the operator: {text}"
);
}
#[tokio::test]
async fn adopt_asks_the_shepherd_to_start_that_dog_with_its_adopted_source() {
for channel in [false, true] {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, mut envelopes) = fake_client_capturing_envelopes(&path).await;
let mut out = Vec::new();
let mut err = Vec::new();
let binary = PathBuf::from("/usr/local/bin/shep-otel");
let _ = adopt_after_config(
&mut streams(&mut out, &mut err),
"otel",
&binary,
channel,
Some(&client),
)
.await;
let sent = envelopes.recv().await.unwrap();
assert_eq!(
sent.body,
Request::EnableDog {
name: "otel".to_string(),
source: DogSource::Adopted {
path: "/usr/local/bin/shep-otel".to_string(),
channel,
},
}
);
}
}
fn write_dog(dir: &Path, answer: &str) -> PathBuf {
let binary = dir.join("shep-otel");
let script =
format!("#!/bin/sh\nif [ \"$1\" = --version ]; then printf '{answer}'; fi\nexit 0\n");
std::fs::write(&binary, script).unwrap();
let mut mode = std::fs::metadata(&binary).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&binary, mode).unwrap();
binary
}
#[tokio::test]
async fn adopt_records_a_channel_ask_only_when_the_dog_makes_one() {
for (answer, asked) in [
(r"shep-otel 0.1.0\nshep-channel: true\n", true),
(r"shep-otel 0.1.0\n", false),
] {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let args = AdoptArgs {
name: Some("otel".to_string()),
path: write_dog(dir.path(), answer),
};
let mut out = Vec::new();
let mut err = Vec::new();
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success);
let written = std::fs::read_to_string(&paths.daemon_config).unwrap();
let cfg = shep_core::config::DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert_eq!(
cfg.daemon.channel_dogs.contains(&"otel".to_string()),
asked,
"{written}"
);
let notices = String::from_utf8(err).unwrap();
assert_eq!(
notices.contains("asked for the shepherd channel"),
asked,
"the operator hears what the dog asked for: {notices}"
);
}
}
#[tokio::test]
async fn adopt_reports_whether_a_shepherd_acted() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, _envelopes) = shep_client::testing::fake_client_answering(&path, |_| {
Response::DogStarted(shep_client::testing::sample_info())
})
.await;
let binary = PathBuf::from("/usr/local/bin/shep-otel");
for (client, expected) in [(Some(&client), true), (None, false)] {
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = streams(&mut out, &mut err);
streams.fmt = Format::Json;
let code = adopt_after_config(&mut streams, "otel", &binary, false, client).await;
assert_eq!(code, ExitCode::Success);
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(
json["data"]["shepherd_acted"],
serde_json::json!(expected),
"client present: {}, payload: {}",
client.is_some(),
String::from_utf8_lossy(&out)
);
}
}
#[tokio::test]
async fn adopt_with_no_shepherd_writes_the_config_and_exits_zero() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let binary = dir.path().join("shep-otel");
std::fs::write(&binary, "#!/bin/sh\nexit 0\n").unwrap();
let mut mode = std::fs::metadata(&binary).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&binary, mode).unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: binary,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success);
let written = std::fs::read_to_string(&paths.daemon_config).unwrap();
assert!(
written.contains("otel"),
"the config edit must still land: {written}"
);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("next shepherd"),
"the operator needs to know the dog is not running yet: {text}"
);
}
#[tokio::test]
async fn adopt_with_no_name_flag_defaults_from_the_stripped_stem() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let binary = dir.path().join("shep-otel");
std::fs::write(&binary, "#!/bin/sh\nexit 0\n").unwrap();
let mut mode = std::fs::metadata(&binary).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&binary, mode).unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
path: binary,
name: None,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success);
let written = std::fs::read_to_string(&paths.daemon_config).unwrap();
let cfg = shep_core::config::DaemonConfig::load(Some(&written), &|_| None).unwrap();
assert!(
cfg.daemon.adopted_dogs.contains_key("otel"),
"the defaulted name must be `otel`, not `shep-otel`: {written}"
);
}
#[tokio::test]
async fn adopt_refuses_a_name_that_collides_with_a_built_in_verb() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let binary = dir.path().join("watchdog");
std::fs::write(&binary, "#!/bin/sh\nexit 0\n").unwrap();
let mut mode = std::fs::metadata(&binary).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&binary, mode).unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
for reserved in ["stop", "ls"] {
let args = AdoptArgs {
path: binary.clone(),
name: Some(reserved.to_string()),
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(
code,
ExitCode::InvalidConfig,
"`{reserved}` must be refused"
);
}
assert!(
!paths.daemon_config.exists(),
"a name collision must never touch shep.toml: {}",
paths.daemon_config.display()
);
}
#[tokio::test]
async fn a_name_collision_is_refused_before_vet_binary_ever_runs() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
path: dir.path().join("nope"),
name: Some("stop".to_string()),
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::InvalidConfig);
let text = String::from_utf8(err).unwrap();
assert!(
text.contains("already a shep verb or alias"),
"the collision must be the reported reason, not vet_binary's own refusal: {text}"
);
assert!(
!text.contains("no file exists at that path"),
"vet_binary must never run on a name that was always going to be refused: {text}"
);
}
#[test]
fn resolve_adopt_path_prefers_a_literal_path_that_exists() {
let dir = tempfile::tempdir().unwrap();
let binary = dir.path().join("thing");
std::fs::write(&binary, "").unwrap();
let resolved = resolve_adopt_path(&binary, None, None);
assert_eq!(resolved, binary);
}
#[test]
fn resolve_adopt_path_expands_a_leading_tilde_against_the_given_home() {
let home = tempfile::tempdir().unwrap();
let binary_dir = home.path().join(".cargo/bin");
std::fs::create_dir_all(&binary_dir).unwrap();
let binary = binary_dir.join("shep-log-rotate");
std::fs::write(&binary, "").unwrap();
let raw = Path::new("~/.cargo/bin/shep-log-rotate");
let resolved = resolve_adopt_path(raw, Some(home.path()), None);
assert_eq!(resolved, binary);
}
#[test]
fn resolve_adopt_path_expands_a_tilde_path_it_cannot_search() {
let home = tempfile::tempdir().unwrap();
let sealed = home.path().join("sealed");
std::fs::create_dir(&sealed).unwrap();
std::fs::write(sealed.join("dog"), "").unwrap();
let mut mode = std::fs::metadata(&sealed).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o000);
std::fs::set_permissions(&sealed, mode.clone()).unwrap();
let hidden = std::fs::metadata(sealed.join("dog")).is_err();
let raw = Path::new("~/sealed/dog");
let resolved = resolve_adopt_path(raw, Some(home.path()), None);
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o700);
std::fs::set_permissions(&sealed, mode).unwrap();
if !hidden {
return;
}
assert_eq!(resolved, sealed.join("dog"));
}
#[test]
fn resolve_adopt_path_falls_back_to_a_path_lookup_for_a_bare_name() {
let dir = tempfile::tempdir().unwrap();
let binary = dir.path().join("shep-log-rotate");
std::fs::write(&binary, "").unwrap();
let mut mode = std::fs::metadata(&binary).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&binary, mode).unwrap();
let path_var = std::ffi::OsString::from(dir.path());
let raw = Path::new("shep-log-rotate");
let resolved = resolve_adopt_path(raw, None, Some(&path_var));
assert_eq!(resolved, binary);
}
#[test]
fn resolve_adopt_path_does_not_path_search_a_name_with_a_directory_component() {
let path_dir = tempfile::tempdir().unwrap();
let decoy = path_dir.path().join("thing");
std::fs::write(&decoy, "").unwrap();
let mut mode = std::fs::metadata(&decoy).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut mode, 0o755);
std::fs::set_permissions(&decoy, mode).unwrap();
let path_var = std::ffi::OsString::from(path_dir.path());
let raw = Path::new("./thing");
let resolved = resolve_adopt_path(raw, None, Some(&path_var));
assert_eq!(
resolved, raw,
"a name with its own directory must never be searched on $PATH"
);
}
#[test]
fn resolve_adopt_path_returns_raw_unchanged_when_nothing_resolves() {
let raw = Path::new("/nonexistent/shep-nothing");
assert_eq!(resolve_adopt_path(raw, None, None), raw);
}
#[test]
fn default_dog_name_strips_one_leading_shep_prefix_and_no_further() {
assert_eq!(
default_dog_name(Path::new("/opt/bin/shep-log-rotate")),
"log-rotate"
);
assert_eq!(default_dog_name(Path::new("/opt/bin/otel")), "otel");
assert_eq!(default_dog_name(Path::new("/opt/bin/shep-")), "shep-");
}
#[test]
fn collides_with_a_verb_covers_names_and_visible_aliases() {
assert!(collides_with_a_verb("stop"), "a real verb must collide");
assert!(collides_with_a_verb("ls"), "flock's own alias must collide");
assert!(
!collides_with_a_verb("watchdog"),
"an arbitrary name must not collide"
);
}
}