use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use shep_client::{Client, ConnectError};
use shep_core::barks;
use shep_core::paths::ShepPaths;
use shep_core::protocol::{DogSource, Request, Response};
use crate::cli::{AdoptArgs, BarksArgs};
use crate::commands::shep_toml::{ShepToml, ShepTomlError};
use crate::exit::ExitCode;
use crate::output::{
BarkRows, DogAdoptedRow, DogDisabledRow, DogEnabledRow, DogRehomedRow, Streams, emit,
write_outcome,
};
const NO_SHEPHERD_ENABLE_STATUS: &str = "will start with the next shepherd";
const NO_SHEPHERD_DISABLE_STATUS: &str = "not running; will not start with the next shepherd";
const DISABLED_STATUS: &str = "stopped";
fn fail_config(streams: &mut Streams<'_>, err: &ShepTomlError) -> ExitCode {
let code = match err {
ShepTomlError::Io { .. } => ExitCode::Failure,
ShepTomlError::Parse { .. } | ShepTomlError::WrongShape { .. } => ExitCode::InvalidConfig,
};
streams.fail(code, &err.to_string())
}
fn dog_source(cfg: &ShepToml, name: &str) -> DogSource {
cfg.adopted_dog_path(name)
.map_or(DogSource::BuiltIn, |path| DogSource::Adopted {
path: path.display().to_string(),
})
}
async fn connect_or_absent(
paths: &ShepPaths,
streams: &mut Streams<'_>,
) -> Result<Option<Client>, ExitCode> {
match Client::connect(&paths.socket).await {
Ok(client) => Ok(Some(client)),
Err(ConnectError::Connect { .. }) => Ok(None),
Err(err) => {
let code = ExitCode::from(&err);
Err(streams.fail(
code,
&format!("{err}; run `shep {}`", crate::VERSION_SKEW_REMEDY),
))
}
}
}
pub async fn enable(streams: &mut Streams<'_>, paths: &ShepPaths, name: &str) -> ExitCode {
let source = match ShepToml::edit(&paths.daemon_config, |cfg| {
let source = dog_source(cfg, name);
cfg.enable_dog(name);
source
}) {
Ok(source) => source,
Err(err) => return fail_config(streams, &err),
};
let client = match connect_or_absent(paths, streams).await {
Ok(client) => client,
Err(code) => return code,
};
enable_after_config(streams, name, &source, client.as_ref()).await
}
async fn enable_after_config(
streams: &mut Streams<'_>,
name: &str,
source: &DogSource,
client: Option<&Client>,
) -> ExitCode {
let Some(client) = client else {
let row = DogEnabledRow {
name: name.to_string(),
source: source.clone(),
shepherd_acted: false,
status: NO_SHEPHERD_ENABLE_STATUS.to_string(),
};
return write_outcome(emit(
&mut *streams.out,
streams.fmt,
"enable",
row,
streams.style,
));
};
let request = Request::EnableDog {
name: name.to_string(),
source: source.clone(),
};
match client.request(request).await {
Ok(Response::DogStarted(info)) => {
let row = DogEnabledRow {
name: name.to_string(),
source: source.clone(),
shepherd_acted: true,
status: info.status.to_string(),
};
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"enable",
row,
streams.style,
))
}
Ok(_) => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
pub async fn disable(streams: &mut Streams<'_>, paths: &ShepPaths, name: &str) -> ExitCode {
let source = match ShepToml::edit(&paths.daemon_config, |cfg| {
let source = dog_source(cfg, name);
cfg.disable_dog(name);
source
}) {
Ok(source) => source,
Err(err) => return fail_config(streams, &err),
};
let client = match connect_or_absent(paths, streams).await {
Ok(client) => client,
Err(code) => return code,
};
disable_after_config(streams, name, &source, client.as_ref()).await
}
async fn disable_after_config(
streams: &mut Streams<'_>,
name: &str,
source: &DogSource,
client: Option<&Client>,
) -> ExitCode {
let Some(client) = client else {
let row = DogDisabledRow {
name: name.to_string(),
source: source.clone(),
shepherd_acted: false,
status: NO_SHEPHERD_DISABLE_STATUS.to_string(),
};
return write_outcome(emit(
&mut *streams.out,
streams.fmt,
"disable",
row,
streams.style,
));
};
match client
.request(Request::DisableDog {
name: name.to_string(),
})
.await
{
Ok(Response::Deleted(_ids)) => {
let row = DogDisabledRow {
name: name.to_string(),
source: source.clone(),
shepherd_acted: true,
status: DISABLED_STATUS.to_string(),
};
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"disable",
row,
streams.style,
))
}
Ok(_) => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
#[cfg_attr(windows, allow(dead_code))]
#[derive(Debug, PartialEq, Eq)]
pub enum AdoptRefusal {
Missing,
NotAFile,
NotExecutable,
WorldWritable {
path: PathBuf,
},
WillNotExec {
reason: String,
},
}
impl std::fmt::Display for AdoptRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Missing => write!(f, "no file exists at that path"),
Self::NotAFile => write!(f, "that path is not a file"),
Self::NotExecutable => write!(f, "no execute bit is set on that file"),
Self::WorldWritable { path } => write!(
f,
"{} is writable by any user on this system, and an adopted dog runs \
with the shepherd's own privileges",
path.display()
),
Self::WillNotExec { reason } => {
write!(f, "this kernel refused to run that file: {reason}")
}
}
}
}
impl core::error::Error for AdoptRefusal {}
pub fn vet_binary(path: &Path, home: &Path, name: &str) -> Result<VettedBinary, AdoptRefusal> {
let metadata = std::fs::metadata(path).map_err(|_| AdoptRefusal::Missing)?;
if !metadata.is_file() {
return Err(AdoptRefusal::NotAFile);
}
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
if metadata.permissions().mode() & 0o111 == 0 {
return Err(AdoptRefusal::NotExecutable);
}
let canonical = path
.canonicalize()
.map(|abs| shep_core::paths::strip_verbatim_prefix(&abs).into_owned())
.map_err(|_| AdoptRefusal::Missing)?;
let group_writable = writability(&canonical)?;
match Command::new(&canonical)
.env("SHEP_HOME", home)
.env("SHEP_DOG_NAME", name)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Err(err) => Err(AdoptRefusal::WillNotExec {
reason: err.to_string(),
}),
Ok(mut child) => {
if let Some(reason) = macos_deferred_exec_failure(&mut child) {
let _ = child.wait();
return Err(AdoptRefusal::WillNotExec { reason });
}
let _ = child.kill();
let _ = child.wait();
Ok(VettedBinary {
path: canonical,
group_writable,
})
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct VettedBinary {
pub path: PathBuf,
pub group_writable: Vec<PathBuf>,
}
fn writability(canonical: &Path) -> Result<Vec<PathBuf>, AdoptRefusal> {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg_attr(windows, allow(unused_mut))]
let mut group_writable = Vec::new();
for candidate in [Some(canonical), canonical.parent()].into_iter().flatten() {
let Ok(metadata) = std::fs::metadata(candidate) else {
continue;
};
#[cfg(windows)]
let _ = &metadata;
#[cfg(unix)]
let mode = metadata.permissions().mode();
#[cfg(unix)]
if mode & 0o002 != 0 {
return Err(AdoptRefusal::WorldWritable {
path: candidate.to_path_buf(),
});
}
#[cfg(unix)]
if mode & 0o020 != 0 {
group_writable.push(candidate.to_path_buf());
}
}
Ok(group_writable)
}
#[cfg(target_os = "macos")]
const PROBE_BUDGET: std::time::Duration = std::time::Duration::from_millis(50);
#[cfg(target_os = "macos")]
const PROBE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_micros(500);
#[cfg(target_os = "macos")]
fn macos_deferred_exec_failure(child: &mut std::process::Child) -> Option<String> {
let start = std::time::Instant::now();
while start.elapsed() < PROBE_BUDGET {
match child.try_wait() {
Ok(Some(status)) if status.code() == Some(126) => {
return Some(
"this kernel could not recognize the file as an executable".to_string(),
);
}
Ok(Some(_)) | Err(_) => return None,
Ok(None) => std::thread::sleep(PROBE_POLL_INTERVAL),
}
}
None
}
#[cfg(not(target_os = "macos"))]
fn macos_deferred_exec_failure(_child: &mut std::process::Child) -> Option<String> {
None
}
fn fail_adopt(streams: &mut Streams<'_>, path: &Path, refusal: &AdoptRefusal) -> ExitCode {
let code = ExitCode::InvalidConfig;
let message = format!("{}: {refusal}", path.display());
streams.fail(code, &message)
}
const GROUP_WRITABLE_NOTICE: &str = "group_writable";
fn warn_group_writable(streams: &mut Streams<'_>, path: &Path) {
let message = format!(
"{} is writable by its group; anyone in that group can replace the binary \
this dog runs, and it runs with the shepherd's own privileges",
path.display()
);
streams.aside(GROUP_WRITABLE_NOTICE, &message);
}
pub async fn adopt(streams: &mut Streams<'_>, paths: &ShepPaths, args: &AdoptArgs) -> ExitCode {
let home = std::env::var_os("HOME").map(PathBuf::from);
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 Err(err) = ShepToml::edit(&paths.daemon_config, |cfg| {
cfg.adopt_dog(&name, &path);
}) {
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, client.as_ref()).await
}
fn resolve_adopt_path(raw: &Path, home: Option<&Path>, path_var: Option<&OsStr>) -> PathBuf {
if raw.exists() {
return raw.to_path_buf();
}
if let Some(expanded) = raw
.to_str()
.and_then(|value| expand_tilde_candidate(value, home))
&& expanded.exists()
{
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,
client: Option<&Client>,
) -> ExitCode {
let source = DogSource::Adopted {
path: path.display().to_string(),
};
let Some(client) = client else {
let row = DogAdoptedRow {
name: name.to_string(),
source,
shepherd_acted: false,
status: NO_SHEPHERD_ENABLE_STATUS.to_string(),
};
return write_outcome(emit(
&mut *streams.out,
streams.fmt,
"adopt",
row,
streams.style,
));
};
let request = Request::EnableDog {
name: name.to_string(),
source: source.clone(),
};
match client.request(request).await {
Ok(Response::DogStarted(info)) => {
let row = DogAdoptedRow {
name: name.to_string(),
source,
shepherd_acted: true,
status: info.status.to_string(),
};
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"adopt",
row,
streams.style,
))
}
Ok(_) => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
pub async fn rehome(streams: &mut Streams<'_>, paths: &ShepPaths, name: &str) -> ExitCode {
let source = match ShepToml::edit(&paths.daemon_config, |cfg| {
let source = cfg.adopted_dog_path(name).map(|path| DogSource::Adopted {
path: path.display().to_string(),
});
cfg.rehome_dog(name);
source
}) {
Ok(source) => source,
Err(err) => return fail_config(streams, &err),
};
let client = match connect_or_absent(paths, streams).await {
Ok(client) => client,
Err(code) => return code,
};
rehome_after_config(streams, name, source, client.as_ref()).await
}
async fn rehome_after_config(
streams: &mut Streams<'_>,
name: &str,
source: Option<DogSource>,
client: Option<&Client>,
) -> ExitCode {
let Some(client) = client else {
let row = DogRehomedRow {
name: name.to_string(),
source,
shepherd_acted: false,
status: NO_SHEPHERD_DISABLE_STATUS.to_string(),
};
return write_outcome(emit(
&mut *streams.out,
streams.fmt,
"rehome",
row,
streams.style,
));
};
match client
.request(Request::DisableDog {
name: name.to_string(),
})
.await
{
Ok(Response::Deleted(_ids)) => {
let row = DogRehomedRow {
name: name.to_string(),
source,
shepherd_acted: true,
status: DISABLED_STATUS.to_string(),
};
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"rehome",
row,
streams.style,
))
}
Ok(_) => {
let message = "the daemon answered with a response this client does not understand";
streams.fail(ExitCode::Internal, message)
}
Err(err) => {
let code = ExitCode::from(&err);
streams.fail(code, &err.to_string())
}
}
}
pub fn barks(streams: &mut Streams<'_>, paths: &ShepPaths, args: &BarksArgs) -> ExitCode {
let mut history = match barks::read(&paths.barks) {
Ok(history) => history,
Err(err) => {
return streams.fail(ExitCode::Failure, &err.to_string());
}
};
if let Some(tail) = args.tail {
let keep_from = history.len().saturating_sub(tail);
history.drain(..keep_from);
}
write_outcome(emit(
&mut *streams.out,
streams.fmt,
"barks",
BarkRows(history),
streams.style,
))
}
#[cfg(all(test, unix))]
mod tests {
use shep_client::testing::{
fake_client_capturing_envelopes, fake_client_replying_err, sample_ack, sample_info,
serve_one_request,
};
use shep_core::protocol::RpcErrorCode;
use super::*;
use crate::cli::Format;
fn streams<'a>(out: &'a mut Vec<u8>, err: &'a mut Vec<u8>) -> Streams<'a> {
Streams {
out,
err,
style: crate::style::Presentation::BARE,
fmt: Format::Table,
}
}
#[tokio::test]
async fn enable_asks_the_shepherd_to_start_that_dog_as_a_built_in() {
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 _ = enable_after_config(
&mut streams(&mut out, &mut err),
"metrics",
&DogSource::BuiltIn,
Some(&client),
)
.await;
let sent = envelopes.recv().await.unwrap();
assert_eq!(
sent.body,
Request::EnableDog {
name: "metrics".to_string(),
source: DogSource::BuiltIn,
}
);
}
#[tokio::test]
async fn enable_of_an_adopted_dog_sends_the_path_the_config_recorded() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.run).unwrap();
ShepToml::edit(&paths.daemon_config, |seed| {
seed.adopt_dog("otel", Path::new("/usr/local/bin/shep-otel"));
})
.unwrap();
let handle = serve_one_request(
&paths.socket,
sample_ack(),
Response::DogStarted(sample_info()),
)
.await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = enable(&mut streams(&mut out, &mut err), &paths, "otel").await;
assert_eq!(code, ExitCode::Success);
let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), handle)
.await
.expect("enable must reach the wire; it hung instead of connecting")
.unwrap();
assert_eq!(
envelope.body,
Request::EnableDog {
name: "otel".to_string(),
source: DogSource::Adopted {
path: "/usr/local/bin/shep-otel".to_string(),
},
}
);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("adopted"),
"the row must render an adopted dog as adopted: {text}"
);
}
#[tokio::test]
async fn enable_reports_a_refusal_as_a_refusal_not_as_no_shepherd() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.run).unwrap();
let refusal = shep_core::protocol::RpcError {
code: RpcErrorCode::ProtocolMismatch,
message: "this daemon speaks protocol 1, this client speaks 2".to_string(),
daemon_version: Some("0.1.8".to_string()),
};
let _daemon = shep_client::testing::fake_daemon(&paths.socket, Err(refusal)).await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = enable(&mut streams(&mut out, &mut err), &paths, "metrics").await;
assert_ne!(code, ExitCode::Success);
let text = String::from_utf8(err).unwrap();
assert!(
!text.contains(NO_SHEPHERD_ENABLE_STATUS),
"a refusal is not an absence: {text}"
);
assert!(text.contains("shep daemon reload"), "{text}");
}
#[tokio::test]
async fn enable_with_no_shepherd_writes_the_config_and_exits_zero() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let mut out = Vec::new();
let mut err = Vec::new();
let code = enable(&mut streams(&mut out, &mut err), &paths, "metrics").await;
assert_eq!(code, ExitCode::Success);
let written = std::fs::read_to_string(&paths.daemon_config).unwrap();
assert!(
written.contains("metrics"),
"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 enable_reports_a_name_collision_with_the_daemons_own_message() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let message =
"a sheep is already registered as `bark`; rename it or give the dog another name";
let (client, _daemon) =
fake_client_replying_err(&path, RpcErrorCode::InvalidConfig, message).await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = enable_after_config(
&mut streams(&mut out, &mut err),
"bark",
&DogSource::BuiltIn,
Some(&client),
)
.await;
assert_eq!(code, ExitCode::InvalidConfig);
let text = String::from_utf8(err).unwrap();
assert!(
text.contains(message),
"the daemon's own message must reach the operator: {text}"
);
}
#[tokio::test]
async fn disable_asks_the_shepherd_to_stop_that_dog() {
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 _ = disable_after_config(
&mut streams(&mut out, &mut err),
"bark",
&DogSource::BuiltIn,
Some(&client),
)
.await;
let sent = envelopes.recv().await.unwrap();
assert_eq!(
sent.body,
Request::DisableDog {
name: "bark".to_string(),
}
);
}
#[tokio::test]
async fn disable_with_no_shepherd_writes_the_config_and_exits_zero() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
ShepToml::edit(&paths.daemon_config, |seed| seed.enable_dog("bark")).unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = disable(&mut streams(&mut out, &mut err), &paths, "bark").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.enabled_dogs.is_empty(),
"disable must remove the name from enabled_dogs: {written}"
);
}
#[tokio::test]
async fn disable_of_a_dog_the_shepherd_does_not_have_reports_not_found() {
let dir = tempfile::tempdir().unwrap();
let path = shep_client::testing::control_address(dir.path());
let (client, _daemon) =
fake_client_replying_err(&path, RpcErrorCode::NotFound, "no sheep matched").await;
let mut out = Vec::new();
let mut err = Vec::new();
let code = disable_after_config(
&mut streams(&mut out, &mut err),
"ghost",
&DogSource::BuiltIn,
Some(&client),
)
.await;
assert_eq!(code, ExitCode::NotFound);
}
fn chmod(path: &Path, mode: u32) {
let mut perms = std::fs::metadata(path).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, mode);
std::fs::set_permissions(path, perms).unwrap();
}
#[test]
fn a_binary_shep_has_never_seen_is_vetted_before_anything_is_written() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
vet_binary(&dir.path().join("nope"), dir.path(), "probe"),
Err(AdoptRefusal::Missing)
);
assert_eq!(
vet_binary(dir.path(), dir.path(), "probe"),
Err(AdoptRefusal::NotAFile)
);
let plain = dir.path().join("plain");
std::fs::write(&plain, "#!/bin/sh\nexit 0\n").unwrap();
assert_eq!(
vet_binary(&plain, dir.path(), "probe"),
Err(AdoptRefusal::NotExecutable)
);
chmod(&plain, 0o755);
let vetted = vet_binary(&plain, dir.path(), "probe").unwrap();
assert_eq!(vetted.path, plain.canonicalize().unwrap());
assert!(
vetted.group_writable.is_empty(),
"an 0o755 binary in an 0o700 directory has nothing to warn about: {vetted:?}"
);
let bogus = dir.path().join("bogus");
std::fs::write(&bogus, b"\x7fELF\x00\x00\x00 not really").unwrap();
chmod(&bogus, 0o755);
assert!(matches!(
vet_binary(&bogus, dir.path(), "probe"),
Err(AdoptRefusal::WillNotExec { .. })
));
}
#[test]
fn a_binary_any_user_can_rewrite_is_refused() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join("dog");
std::fs::write(&bin, "#!/bin/sh\nexit 0\n").unwrap();
chmod(&bin, 0o757);
assert_eq!(
vet_binary(&bin, dir.path(), "probe"),
Err(AdoptRefusal::WorldWritable {
path: bin.canonicalize().unwrap(),
}),
"a world-writable binary must be refused"
);
chmod(&bin, 0o755);
chmod(dir.path(), 0o777);
assert_eq!(
vet_binary(&bin, dir.path(), "probe"),
Err(AdoptRefusal::WorldWritable {
path: bin.canonicalize().unwrap().parent().unwrap().to_path_buf(),
}),
"a world-writable directory must be refused too"
);
chmod(dir.path(), 0o700);
}
#[tokio::test]
async fn a_group_writable_binary_is_adopted_with_a_warning() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let deploy = dir.path().join("deploy");
std::fs::create_dir(&deploy).unwrap();
let bin = deploy.join("shep-otel");
std::fs::write(&bin, "#!/bin/sh\nexit 0\n").unwrap();
chmod(&bin, 0o775);
chmod(&deploy, 0o775);
let vetted = vet_binary(&bin, dir.path(), "otel").unwrap();
assert_eq!(
vetted.group_writable,
vec![bin.canonicalize().unwrap(), deploy.canonicalize().unwrap()],
"both the binary and its directory are group-writable"
);
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin.clone(),
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success, "group-writable is a warning");
let text = String::from_utf8(err).unwrap();
assert!(
text.contains(&bin.canonicalize().unwrap().display().to_string()),
"the warning names the path: {text}"
);
assert!(
text.contains("group"),
"the warning says what the risk is: {text}"
);
}
#[tokio::test]
async fn a_refused_adopt_leaves_the_config_untouched() {
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);
assert!(
!paths.daemon_config.exists(),
"a refused adopt must never touch shep.toml: {}",
paths.daemon_config.display()
);
}
#[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() {
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,
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(),
},
}
);
}
#[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_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"
);
}
#[tokio::test]
async fn rehome_forgets_everything_disable_deliberately_keeps() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
ShepToml::edit(&paths.daemon_config, |seed| {
seed.adopt_dog("otel", Path::new("/usr/local/bin/shep-otel"));
})
.unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = rehome(&mut streams(&mut out, &mut err), &paths, "otel").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.enabled_dogs.is_empty(),
"rehome must remove the name from enabled_dogs: {written}"
);
assert!(
!cfg.daemon.adopted_dogs.contains_key("otel"),
"rehome must forget the adopted_dogs entry disable deliberately keeps: {written}"
);
assert!(
!cfg.dog.contains_key("otel"),
"rehome must remove [dog.otel] too, unlike disable: {written}"
);
}
#[tokio::test]
async fn rehome_asks_the_shepherd_to_stop_that_dog() {
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 _ = rehome_after_config(
&mut streams(&mut out, &mut err),
"otel",
Some(DogSource::Adopted {
path: "/usr/local/bin/shep-otel".to_string(),
}),
Some(&client),
)
.await;
let sent = envelopes.recv().await.unwrap();
assert_eq!(
sent.body,
Request::DisableDog {
name: "otel".to_string(),
}
);
}
#[tokio::test]
async fn rehome_with_no_shepherd_writes_the_config_and_exits_zero() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
ShepToml::edit(&paths.daemon_config, |seed| {
seed.adopt_dog("otel", Path::new("/usr/local/bin/shep-otel"));
})
.unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = rehome(&mut streams(&mut out, &mut err), &paths, "otel").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.enabled_dogs.is_empty());
assert!(!cfg.daemon.adopted_dogs.contains_key("otel"));
}
use shep_core::barks::{self, Bark, SinkOutcome};
fn bark_for(subject: &str, at_ms: u64) -> Bark {
Bark {
at_ms,
rule: "watchdog".to_string(),
subject: subject.to_string(),
message: "restart budget exhausted".to_string(),
sinks: vec![SinkOutcome {
sink: "ops".to_string(),
error: None,
}],
}
}
#[test]
fn barks_renders_the_ring_newest_last_with_no_client_anywhere_in_reach() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.home).unwrap();
barks::append(&paths.barks, &bark_for("web", 1), barks::DEFAULT_MAX_BYTES).unwrap();
barks::append(
&paths.barks,
&bark_for("worker", 2),
barks::DEFAULT_MAX_BYTES,
)
.unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = barks(
&mut streams(&mut out, &mut err),
&paths,
&BarksArgs { tail: None },
);
assert_eq!(code, ExitCode::Success);
let text = String::from_utf8(out).unwrap();
let web_at = text.find("web").expect("the older bark must be rendered");
let worker_at = text
.find("worker")
.expect("the newer bark must be rendered");
assert!(
web_at < worker_at,
"newest last: web (older) must render before worker (newer): {text}"
);
}
#[test]
fn tail_shows_only_the_most_recent_n_barks() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.home).unwrap();
for (subject, at_ms) in [("first", 1), ("second", 2), ("third", 3)] {
barks::append(
&paths.barks,
&bark_for(subject, at_ms),
barks::DEFAULT_MAX_BYTES,
)
.unwrap();
}
let mut out = Vec::new();
let mut err = Vec::new();
let code = barks(
&mut streams(&mut out, &mut err),
&paths,
&BarksArgs { tail: Some(2) },
);
assert_eq!(code, ExitCode::Success);
let text = String::from_utf8(out).unwrap();
assert!(
!text.contains("first"),
"--tail 2 must drop the oldest of three: {text}"
);
assert!(text.contains("second"), "{text}");
assert!(text.contains("third"), "{text}");
}
#[test]
fn tail_larger_than_the_ring_shows_everything() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.home).unwrap();
barks::append(&paths.barks, &bark_for("web", 1), barks::DEFAULT_MAX_BYTES).unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = barks(
&mut streams(&mut out, &mut err),
&paths,
&BarksArgs { tail: Some(50) },
);
assert_eq!(code, ExitCode::Success);
assert!(String::from_utf8(out).unwrap().contains("web"));
}
#[test]
fn no_ring_file_yet_is_an_empty_history_not_a_failure() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let mut out = Vec::new();
let mut err = Vec::new();
let code = barks(
&mut streams(&mut out, &mut err),
&paths,
&BarksArgs { tail: None },
);
assert_eq!(code, ExitCode::Success);
let text = String::from_utf8(out).unwrap();
assert!(
text.contains("WHEN"),
"an empty history still prints its header row: {text}"
);
}
#[test]
fn a_corrupt_trailing_line_costs_one_record_not_the_whole_read() {
use std::io::Write as _;
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(&paths.home).unwrap();
barks::append(&paths.barks, &bark_for("web", 1), barks::DEFAULT_MAX_BYTES).unwrap();
std::fs::OpenOptions::new()
.append(true)
.open(&paths.barks)
.unwrap()
.write_all(b"{\"at_ms\": 2, \"rul\n")
.unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = barks(
&mut streams(&mut out, &mut err),
&paths,
&BarksArgs { tail: None },
);
assert_eq!(code, ExitCode::Success);
assert!(String::from_utf8(out).unwrap().contains("web"));
}
}