use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use shep_client::{Client, ConnectError};
use shep_core::barks;
use shep_core::dogs::{DogVersion, SCHEMA_FLAG, VERSION_FLAG, parse_version_answer};
use shep_core::paths::{ShepPaths, user_home};
use shep_core::protocol::{DogSource, MIN_SUPPORTED, Request, Response, SelectorSpec};
use crate::cli::{AdoptArgs, BarksArgs};
use crate::commands::dog_migration::{self, DogMigrationError};
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 fail_dogs_config(streams: &mut Streams<'_>, err: &DogMigrationError) -> ExitCode {
let code = match err {
DogMigrationError::Parse(_) => ExitCode::InvalidConfig,
_ => ExitCode::Failure,
};
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),
))
}
}
}
#[derive(Debug)]
pub(crate) enum EnableRefusal {
Config(ShepTomlError),
UnknownDog { adopted: Vec<String> },
}
impl From<ShepTomlError> for EnableRefusal {
fn from(err: ShepTomlError) -> Self {
Self::Config(err)
}
}
#[cfg_attr(windows, allow(clippy::result_large_err))]
pub(crate) fn enable_in_config(path: &Path, name: &str) -> Result<DogSource, EnableRefusal> {
ShepToml::try_edit(path, |cfg| {
let source = dog_source(cfg, name);
if matches!(source, DogSource::BuiltIn) && !crate::dog::BUILT_IN_DOGS.contains(&name) {
return Err(EnableRefusal::UnknownDog {
adopted: cfg.adopted_dog_names(),
});
}
cfg.enable_dog(name);
Ok(source)
})
}
pub async fn enable(streams: &mut Streams<'_>, paths: &ShepPaths, name: &str) -> ExitCode {
let source = match enable_in_config(&paths.daemon_config, name) {
Ok(source) => source,
Err(EnableRefusal::Config(err)) => return fail_config(streams, &err),
Err(EnableRefusal::UnknownDog { adopted }) => {
return fail_enable_unknown_dog(streams, name, &adopted);
}
};
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
}
fn fail_enable_unknown_dog(streams: &mut Streams<'_>, name: &str, adopted: &[String]) -> ExitCode {
let valid: Vec<String> = crate::dog::BUILT_IN_DOGS
.iter()
.map(|built_in| format!("{built_in:?}"))
.chain(adopted.iter().map(|dog| format!("{dog:?}")))
.collect();
let message = format!(
"`{name}` is not a dog; valid names are {} -- if you meant a third-party dog, \
run `shep adopt {name}` first",
join_with_and(&valid)
);
streams.fail(ExitCode::InvalidConfig, &message)
}
fn join_with_and(items: &[String]) -> String {
match items {
[] => String::new(),
[only] => only.clone(),
[first, second] => format!("{first} and {second}"),
[rest @ .., last] => format!("{}, and {last}", rest.join(", ")),
}
}
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(crate) fn disable_in_config(path: &Path, name: &str) -> Result<DogSource, ShepTomlError> {
ShepToml::edit(path, |cfg| {
let source = dog_source(cfg, name);
cfg.disable_dog(name);
source
})
}
pub async fn disable(streams: &mut Streams<'_>, paths: &ShepPaths, name: &str) -> ExitCode {
let source = match disable_in_config(&paths.daemon_config, name) {
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,
},
ProtocolMismatch {
dog: u32,
min: u32,
},
}
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}")
}
Self::ProtocolMismatch { dog, min } => write!(
f,
"this dog was built for shep protocol {dog}, and this shep needs {min} or \
newer; reinstall the dog without --locked so it builds against the current \
shep-core, or run a shep that accepts protocol {dog}"
),
}
}
}
impl core::error::Error for AdoptRefusal {}
pub fn vet_binary(path: &Path, home: &Path, name: &str) -> Result<VettedBinary, AdoptRefusal> {
vet_binary_within(path, home, name, VERSION_BUDGET)
}
pub fn vet_binary_within(
path: &Path,
home: &Path,
name: &str,
budget: Duration,
) -> 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)?;
let answer = ask_version(&canonical, home, name, budget)?;
if let Some(dog) = answer.as_ref().and_then(|answer| answer.protocol)
&& dog < MIN_SUPPORTED
{
return Err(AdoptRefusal::ProtocolMismatch {
dog,
min: MIN_SUPPORTED,
});
}
let schema = ask_schema(&canonical, home, name, budget);
Ok(VettedBinary {
path: canonical,
group_writable,
answer,
schema,
})
}
fn probe_env() -> Vec<(String, String)> {
#[cfg(unix)]
const INHERITED: &[&str] = &["HOME", "USER", "LANG", "TZ"];
#[cfg(unix)]
const DEFAULT_PATH: &str = "/usr/local/bin:/usr/bin:/bin";
#[cfg(windows)]
const INHERITED: &[&str] = &[
"SystemRoot",
"SystemDrive",
"windir",
"TEMP",
"TMP",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"COMSPEC",
"PATHEXT",
"NUMBER_OF_PROCESSORS",
"PROCESSOR_ARCHITECTURE",
];
#[cfg(windows)]
const DEFAULT_PATH: &str = r"C:\Windows\system32;C:\Windows;C:\Windows\System32\Wbem";
let path = std::env::var("PATH")
.ok()
.filter(|p| !p.is_empty())
.unwrap_or_else(|| DEFAULT_PATH.to_string());
let mut env = vec![("PATH".to_string(), path)];
env.extend(
INHERITED
.iter()
.filter_map(|key| std::env::var(key).ok().map(|v| ((*key).to_string(), v))),
);
env
}
fn ask(
path: &Path,
flag: &str,
home: &Path,
name: &str,
budget: Duration,
) -> Result<Option<String>, AdoptRefusal> {
let mut command = Command::new(path);
command
.arg(flag)
.env_clear()
.envs(probe_env())
.env("SHEP_HOME", home)
.env("SHEP_DOG_NAME", name)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt as _;
command.process_group(0);
}
match command.spawn() {
Err(err) => Err(AdoptRefusal::WillNotExec {
reason: err.to_string(),
}),
Ok(mut child) => {
let reading = child.stdout.take().map(read_in_background);
if let Some(reason) = macos_deferred_exec_failure(&mut child) {
let _ = child.wait();
return Err(AdoptRefusal::WillNotExec { reason });
}
let answer = answer_text(&mut child, reading, budget);
kill_probe_tree(&mut child);
let _ = child.wait();
Ok(answer)
}
}
}
fn kill_probe_tree(child: &mut Child) {
let _ = child.kill();
#[cfg(unix)]
if let Ok(pid) = i32::try_from(child.id())
&& pid > 0
{
let group = nix::unistd::Pid::from_raw(-pid);
let _ = nix::sys::signal::kill(group, nix::sys::signal::Signal::SIGKILL);
}
}
fn ask_version(
path: &Path,
home: &Path,
name: &str,
budget: Duration,
) -> Result<Option<DogVersion>, AdoptRefusal> {
Ok(ask(path, VERSION_FLAG, home, name, budget)?
.as_deref()
.and_then(parse_version_answer))
}
pub(crate) fn ask_schema(path: &Path, home: &Path, name: &str, budget: Duration) -> DogSchema {
match ask(path, SCHEMA_FLAG, home, name, budget) {
Ok(Some(text)) if !text.trim().is_empty() => match serde_json::from_str(&text) {
Ok(schema) => DogSchema::Published(schema),
Err(_) => DogSchema::Unreadable,
},
Ok(_) | Err(_) => DogSchema::Silent,
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct VettedBinary {
pub path: PathBuf,
pub group_writable: Vec<PathBuf>,
pub answer: Option<DogVersion>,
pub schema: DogSchema,
}
#[derive(PartialEq, Eq)]
pub enum DogSchema {
Published(serde_json::Value),
Silent,
Unreadable,
}
impl core::fmt::Debug for DogSchema {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Published(_) => f.write_str("Published(..)"),
Self::Silent => f.write_str("Silent"),
Self::Unreadable => f.write_str("Unreadable"),
}
}
}
pub(crate) const VERSION_BUDGET: Duration = Duration::from_secs(1);
const VERSION_POLL_INTERVAL: Duration = Duration::from_millis(2);
const PROBE_OUTPUT_LIMIT: u64 = 1024 * 1024;
fn read_in_background(stdout: std::process::ChildStdout) -> Receiver<String> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
use std::io::Read as _;
let mut text = String::new();
let _ = stdout.take(PROBE_OUTPUT_LIMIT).read_to_string(&mut text);
let _ = tx.send(text);
});
rx
}
fn answer_text(
child: &mut Child,
reading: Option<Receiver<String>>,
budget: Duration,
) -> Option<String> {
let reading = reading?;
let started = Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) if status.success() => break,
Ok(Some(_)) | Err(_) => return None,
Ok(None) if started.elapsed() >= budget => return None,
Ok(None) => std::thread::sleep(VERSION_POLL_INTERVAL),
}
}
reading.recv_timeout(budget).ok()
}
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);
}
const DOG_VERSION_NOTICE: &str = "dog_version";
fn report_dog_version(streams: &mut Streams<'_>, name: &str, answer: &DogVersion) {
let message = match answer.protocol {
Some(protocol) => format!(
"{name} reports version {}, shep protocol {protocol}",
answer.version
),
None => format!(
"{name} reports version {} and names no shep protocol, so whether it can \
speak to this shep is unknown until it connects",
answer.version
),
};
streams.aside(DOG_VERSION_NOTICE, &message);
}
const DOG_SCHEMA_UNREADABLE_NOTICE: &str = "dog_schema_unreadable";
fn warn_unreadable_schema(streams: &mut Streams<'_>, name: &str) {
let message = format!(
"{name} answered `{SCHEMA_FLAG}` with something that is not JSON, so shep has \
no description of its settings and they stay a hand-edited section. The dog \
is adopted and runs normally; this is a bug to report to whoever wrote it"
);
streams.aside(DOG_SCHEMA_UNREADABLE_NOTICE, &message);
}
const DOG_BINARY_SKEW_NOTICE: &str = "dog_binary_skew";
pub fn warn_of_a_dog_a_restart_would_break(
streams: &mut Streams<'_>,
paths: &ShepPaths,
selectors: &[SelectorSpec],
budget: Duration,
) {
for selector in selectors {
let SelectorSpec::Name(name) = selector else {
continue;
};
let Ok(Some(binary)) = ShepToml::adopted_dog_path_readonly(&paths.daemon_config, name)
else {
continue;
};
let Ok(Some(answer)) = ask_version(&binary, &paths.home, name, budget) else {
continue;
};
let Some(disk) = answer.protocol else {
continue;
};
if disk >= MIN_SUPPORTED {
continue;
}
let message = format!(
"`{name}`'s binary at {} was built for shep protocol {disk}, and this shep \
needs {MIN_SUPPORTED} or newer; restarting it brings it back on that binary, \
unable to connect. Run a shep that accepts protocol {disk}, or reinstall the \
dog against protocol {MIN_SUPPORTED}, and restart it again",
binary.display()
);
streams.aside(DOG_BINARY_SKEW_NOTICE, &message);
}
}
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);
}
if vetted.schema == DogSchema::Unreadable {
warn_unreadable_schema(streams, &name);
}
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),
};
if let Err(err) = dog_migration::forget_dog_section(&paths.dogs_config, name) {
return fail_dogs_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::PROTOCOL_VERSION;
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;
#[test]
fn enable_refusal_debug_never_prints_the_document() {
let path = std::path::PathBuf::from("/home/ada/.shep/shep.toml");
let secret = "https://hooks.example.com/services/T00/B00/super-secret-token";
let broken = format!("[dog.bark]\nwebhook = \"{secret}\"\n[daemon\n");
let source = broken.parse::<toml_edit::DocumentMut>().unwrap_err();
let wrong_shape = EnableRefusal::Config(ShepTomlError::WrongShape {
path: path.clone(),
key: "style",
found: "string",
});
assert_eq!(
format!("{wrong_shape:?}"),
"Config(WrongShape { path: \"/home/ada/.shep/shep.toml\", key: \"style\", \
found: \"string\" })"
);
let parse = EnableRefusal::Config(ShepTomlError::Parse { path, source });
let debug = format!("{parse:?}");
assert!(
!debug.contains(secret),
"the document must never reach Debug: {debug}"
);
assert!(!debug.contains("webhook"), "{debug}");
assert_eq!(
debug,
"Config(Parse { path: \"/home/ada/.shep/shep.toml\", message: \"invalid table \
header\\nexpected `.`, `]`\" })"
);
let unknown = EnableRefusal::UnknownDog {
adopted: vec!["otel".to_string()],
};
assert_eq!(format!("{unknown:?}"), "UnknownDog { adopted: [\"otel\"] }");
}
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,
}
}
#[test]
fn enable_in_config_writes_the_name_and_reports_a_built_in_source() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
let source = enable_in_config(&path, "metrics").unwrap();
assert!(matches!(source, DogSource::BuiltIn));
assert!(
ShepToml::read_only(&path)
.unwrap()
.enabled_dog_names()
.contains(&"metrics".to_string())
);
}
#[test]
fn enable_in_config_refuses_a_name_that_is_no_dog_and_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(&path, "").unwrap();
let refusal = enable_in_config(&path, "nonsense");
assert!(matches!(refusal, Err(EnableRefusal::UnknownDog { .. })));
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"",
"a refused enable leaves shep.toml untouched"
);
}
#[test]
fn disable_in_config_removes_the_name_and_keeps_the_adoption() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("shep.toml");
std::fs::write(
&path,
"[daemon]\nenabled_dogs = [\"otel\"]\n\n[daemon.adopted_dogs]\notel = \"/usr/local/bin/shep-otel\"\n",
)
.unwrap();
let source = disable_in_config(&path, "otel").unwrap();
assert!(matches!(source, DogSource::Adopted { .. }));
let cfg = ShepToml::read_only(&path).unwrap();
assert!(cfg.enabled_dog_names().is_empty());
assert_eq!(
cfg.adopted_dog_names(),
vec!["otel".to_string()],
"disable is not rehome, so the adoption survives"
);
}
#[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 enabling_a_dog_does_not_leave_a_section_that_refuses_the_next_boot() {
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("[dog"),
"enable must write no dog section into shep.toml: {written}"
);
std::fs::write(&paths.dogs_config, "[metrics]\nbind = \"127.0.0.1:9615\"\n").unwrap();
crate::commands::dog_migration::migrate_dog_sections(&paths)
.expect("a boot after an enable must not refuse over a section enable wrote");
}
#[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_refuses_a_name_that_is_neither_built_in_nor_adopted() {
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, "pydog").await;
assert_eq!(code, ExitCode::InvalidConfig);
let text = String::from_utf8(err).unwrap();
assert!(
text.contains("pydog"),
"the refusal must name the name: {text}"
);
assert!(
text.contains("shep adopt pydog"),
"the refusal must name the way out, the way `shep adopt`'s own \
name-collision refusal does: {text}"
);
assert!(
!paths.daemon_config.exists(),
"a refused enable must leave the config untouched -- `try_edit` \
skips `save`, so a `$SHEP_HOME` that had no `shep.toml` still \
has none"
);
}
#[tokio::test]
async fn enable_refusal_names_the_adopted_dogs_alongside_the_built_ins() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
ShepToml::edit(&paths.daemon_config, |cfg| {
cfg.adopt_dog("otel", Path::new("/usr/local/bin/shep-otel"));
})
.unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = enable(&mut streams(&mut out, &mut err), &paths, "pydog").await;
assert_eq!(code, ExitCode::InvalidConfig);
let text = String::from_utf8(err).unwrap();
for expected in ["\"metrics\"", "\"bark\"", "\"otel\""] {
assert!(
text.contains(expected),
"the refusal must name {expected} among the valid names: {text}"
);
}
}
#[tokio::test]
async fn enable_still_accepts_a_name_adopt_recorded() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
ShepToml::edit(&paths.daemon_config, |cfg| {
cfg.adopt_dog("otel", Path::new("/usr/local/bin/shep-otel"));
})
.unwrap();
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, "{}", String::from_utf8_lossy(&err));
let written = std::fs::read_to_string(&paths.daemon_config).unwrap();
assert!(written.contains("otel"), "{written}");
}
#[tokio::test]
async fn disable_still_removes_a_name_enable_would_now_refuse() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
std::fs::create_dir_all(paths.daemon_config.parent().unwrap()).unwrap();
std::fs::write(
&paths.daemon_config,
"[daemon]\nenabled_dogs = [\"pydog\", \"metrics\"]\n",
)
.unwrap();
let mut out = Vec::new();
let mut err = Vec::new();
let code = disable(&mut streams(&mut out, &mut err), &paths, "pydog").await;
assert_eq!(code, ExitCode::Success, "{}", String::from_utf8_lossy(&err));
let written = std::fs::read_to_string(&paths.daemon_config).unwrap();
assert!(
!written.contains("pydog"),
"disable is the escape hatch out of a config enable would now \
refuse to write: {written}"
);
assert!(
written.contains("metrics"),
"and it touches nothing else: {written}"
);
}
#[test]
fn join_with_and_reads_as_an_english_list() {
let one = ["a".to_string()];
let two = ["a".to_string(), "b".to_string()];
let three = ["a".to_string(), "b".to_string(), "c".to_string()];
assert_eq!(join_with_and(&[]), "");
assert_eq!(join_with_and(&one), "a");
assert_eq!(join_with_and(&two), "a and b");
assert_eq!(join_with_and(&three), "a, b, and c");
}
#[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_within(&dir.path().join("nope"), dir.path(), "probe", TEST_BUDGET),
Err(AdoptRefusal::Missing)
);
assert_eq!(
vet_binary_within(dir.path(), dir.path(), "probe", TEST_BUDGET),
Err(AdoptRefusal::NotAFile)
);
let plain = dir.path().join("plain");
std::fs::write(&plain, "#!/bin/sh\nexit 0\n").unwrap();
assert_eq!(
vet_binary_within(&plain, dir.path(), "probe", TEST_BUDGET),
Err(AdoptRefusal::NotExecutable)
);
chmod(&plain, 0o755);
let vetted = vet_binary_within(&plain, dir.path(), "probe", TEST_BUDGET).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_within(&bogus, dir.path(), "probe", TEST_BUDGET),
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_within(&bin, dir.path(), "probe", TEST_BUDGET),
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_within(&bin, dir.path(), "probe", TEST_BUDGET),
Err(AdoptRefusal::WorldWritable {
path: bin.canonicalize().unwrap().parent().unwrap().to_path_buf(),
}),
"a world-writable directory must be refused too"
);
chmod(dir.path(), 0o700);
}
fn probe_script(dir: &Path, name: &str, version_body: &str, schema_body: &str) -> PathBuf {
let path = dir.join(name);
let body = format!(
"#!/bin/sh\ncase \"$1\" in\n--version)\n{version_body}\n;;\n\
--schema)\n{schema_body}\n;;\nesac\n"
);
std::fs::write(&path, body).unwrap();
chmod(&path, 0o755);
path
}
fn dog_script(dir: &Path, name: &str, body: &str) -> PathBuf {
probe_script(dir, name, body, "exit 0")
}
#[tokio::test]
async fn a_dog_above_this_protocol_is_adopted() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let newer = PROTOCOL_VERSION + 1;
let bin = dog_script(
dir.path(),
"shep-otel",
&format!("echo 'shep-otel 0.1.3'\necho 'shep-protocol: {newer}'"),
);
assert!(vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).is_ok());
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success, "a newer protocol is not a refusal");
assert!(
paths.daemon_config.exists(),
"an accepted adopt writes shep.toml"
);
}
#[tokio::test]
async fn a_dog_below_the_floor_is_refused_at_adopt() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let stale = MIN_SUPPORTED.saturating_sub(1);
let bin = dog_script(
dir.path(),
"shep-otel",
&format!("echo 'shep-otel 0.1.3'\necho 'shep-protocol: {stale}'"),
);
assert_eq!(
vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET),
Err(AdoptRefusal::ProtocolMismatch {
dog: stale,
min: MIN_SUPPORTED,
})
);
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
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(&stale.to_string()) && text.contains(&MIN_SUPPORTED.to_string()),
"the refusal names both numbers: {text}"
);
assert!(
text.contains("--locked") && text.contains("run a shep that accepts protocol"),
"the refusal names both fixes, and picks neither: {text}"
);
assert!(
!paths.daemon_config.exists(),
"a refused adopt must not write shep.toml"
);
}
fn rewrite_dog_protocol(bin: &Path, protocol: u32) {
std::fs::write(
bin,
format!(
"#!/bin/sh\ncase \"$1\" in\n--version)\necho 'shep-otel 0.1.3'\n\
echo 'shep-protocol: {protocol}'\n;;\nesac\n"
),
)
.unwrap();
chmod(bin, 0o755);
}
#[tokio::test]
async fn no_restart_warning_for_a_dog_inside_the_window() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let bin = dog_script(
dir.path(),
"shep-otel",
&format!("echo 'shep-otel 0.1.3'\necho 'shep-protocol: {PROTOCOL_VERSION}'"),
);
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin.clone(),
};
let mut adopt_out = Vec::new();
let mut adopt_err = Vec::new();
let code = adopt(&mut streams(&mut adopt_out, &mut adopt_err), &paths, &args).await;
assert_eq!(code, ExitCode::Success, "the fixture must adopt cleanly");
for protocol in [PROTOCOL_VERSION + 1, MIN_SUPPORTED] {
rewrite_dog_protocol(&bin, protocol);
let mut out = Vec::new();
let mut err = Vec::new();
let selectors = vec![SelectorSpec::Name("otel".to_string())];
warn_of_a_dog_a_restart_would_break(
&mut streams(&mut out, &mut err),
&paths,
&selectors,
TEST_BUDGET,
);
let text = String::from_utf8(err).unwrap();
assert!(
!text.contains(DOG_BINARY_SKEW_NOTICE),
"protocol {protocol} is inside the window, so no warning belongs here: {text}"
);
}
}
#[tokio::test]
async fn a_restart_warning_fires_for_a_dog_below_the_floor() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let bin = dog_script(
dir.path(),
"shep-otel",
&format!("echo 'shep-otel 0.1.3'\necho 'shep-protocol: {PROTOCOL_VERSION}'"),
);
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin.clone(),
};
let mut adopt_out = Vec::new();
let mut adopt_err = Vec::new();
let code = adopt(&mut streams(&mut adopt_out, &mut adopt_err), &paths, &args).await;
assert_eq!(code, ExitCode::Success, "the fixture must adopt cleanly");
rewrite_dog_protocol(&bin, MIN_SUPPORTED.saturating_sub(1));
let mut out = Vec::new();
let mut err = Vec::new();
let selectors = vec![SelectorSpec::Name("otel".to_string())];
warn_of_a_dog_a_restart_would_break(
&mut streams(&mut out, &mut err),
&paths,
&selectors,
TEST_BUDGET,
);
let text = String::from_utf8(err).unwrap();
assert!(
text.contains(DOG_BINARY_SKEW_NOTICE),
"a dog below the floor is exactly what this warning is for: {text}"
);
assert!(
text.contains(&MIN_SUPPORTED.to_string()),
"the warning names the floor, not one exact version: {text}"
);
}
#[tokio::test]
async fn a_dog_whose_version_is_nothing_like_sheps_is_still_adopted() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let bin = dog_script(
dir.path(),
"shep-otel",
&format!("echo 'shep-otel 9.9.9-rc1'\necho 'shep-protocol: {PROTOCOL_VERSION}'"),
);
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
assert_eq!(
vetted.answer,
Some(DogVersion {
version: "9.9.9-rc1".to_string(),
protocol: Some(PROTOCOL_VERSION),
})
);
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(
code,
ExitCode::Success,
"a version difference is not a refusal"
);
let text = String::from_utf8(err).unwrap();
assert!(
text.contains("9.9.9-rc1"),
"the version it answered is reported: {text}"
);
}
#[tokio::test]
async fn a_dog_that_does_not_answer_is_adopted_with_an_unknown_protocol() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let bin = dog_script(dir.path(), "shep-otel", "exit 0");
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
assert_eq!(vetted.answer, None, "silence is an unknown, not an answer");
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(
code,
ExitCode::Success,
"a dog predating the contract is adoptable"
);
let cfg = std::fs::read_to_string(&paths.daemon_config).unwrap();
assert!(cfg.contains("otel"), "and it is recorded: {cfg}");
}
#[tokio::test]
async fn a_dog_that_names_no_protocol_is_adopted_with_the_version_it_gave() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let bin = dog_script(dir.path(), "shep-otel", "echo 'shep-otel 0.1.3'");
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
assert_eq!(
vetted.answer,
Some(DogVersion {
version: "0.1.3".to_string(),
protocol: None,
})
);
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success);
let text = String::from_utf8(err).unwrap();
assert!(
text.contains("0.1.3") && text.contains("unknown"),
"the operator hears the version and that the protocol is unknown: {text}"
);
}
const TEST_BUDGET: Duration = Duration::from_secs(30);
#[cfg(unix)]
#[test]
fn a_probe_runs_with_the_daemons_environment_and_not_the_operators() {
assert!(
std::env::var("CARGO_PKG_NAME").is_ok(),
"the sentinel has to be in this process for its absence downstream to mean anything"
);
let dir = tempfile::tempdir().unwrap();
let bin = dog_script(
dir.path(),
"shep-otel",
"echo \"shep-otel ${CARGO_PKG_NAME:-clean}\"",
);
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
let version = vetted.answer.expect("the candidate answered").version;
assert_eq!(
version, "clean",
"the operator's environment reached a candidate: it saw CARGO_PKG_NAME"
);
}
#[test]
fn a_candidate_that_never_exits_does_not_hang_the_vet() {
let dir = tempfile::tempdir().unwrap();
let bin = dog_script(dir.path(), "shep-otel", "sleep 30");
let started = std::time::Instant::now();
let vetted = vet_binary_within(&bin, dir.path(), "otel", VERSION_BUDGET).unwrap();
let elapsed = started.elapsed();
assert_eq!(
vetted.answer, None,
"a candidate that never answered is unknown"
);
assert!(
elapsed < Duration::from_secs(10),
"the vet is bounded by its own budget, not by the candidate: {elapsed:?}"
);
}
#[test]
fn a_candidate_that_will_not_stop_talking_is_read_no_further_than_the_cap() {
let dir = tempfile::tempdir().unwrap();
let chunk = "a".repeat(1024);
let chunks = PROBE_OUTPUT_LIMIT / 1024 * 2;
let bin = probe_script(
dir.path(),
"shep-otel",
&format!(
"trap '' PIPE\ni=0\nwhile [ $i -lt {chunks} ]; do \
printf '%s' '{chunk}'; i=$((i+1)); done\nexit 0"
),
"exit 0",
);
let answer = ask(&bin, VERSION_FLAG, dir.path(), "otel", TEST_BUDGET)
.expect("what a candidate prints is never a refusal")
.expect("it exited 0, so it answered");
assert_eq!(
answer.len() as u64,
PROBE_OUTPUT_LIMIT,
"the read stops at the cap, whatever the candidate does after it"
);
}
#[test]
fn an_answer_from_a_failed_run_is_not_an_answer() {
let dir = tempfile::tempdir().unwrap();
let named = PROTOCOL_VERSION + 1;
let bin = dog_script(
dir.path(),
"shep-otel",
&format!("echo 'shep-otel 0.1.3'\necho 'shep-protocol: {named}'\nexit 3"),
);
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
assert_eq!(vetted.answer, None);
}
#[test]
fn output_that_answers_nothing_shep_asked_is_not_a_refusal() {
let dir = tempfile::tempdir().unwrap();
let bin = dog_script(
dir.path(),
"shep-otel",
"echo 'error: unrecognized option --version'",
);
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
assert_eq!(
vetted.answer,
Some(DogVersion {
version: "--version".to_string(),
protocol: None,
}),
"the last field of line 1 is taken as the version, whatever it is"
);
}
#[test]
fn a_future_shep_key_is_ignored_rather_than_breaking_the_parser() {
let answer = parse_version_answer(
"some-other-crate-name 0.4.0\n\nshep-channel: 7\nother: whatever\n\
shep-protocol: 2\nshep-lambs: 9\n",
);
assert_eq!(
answer,
Some(DogVersion {
version: "0.4.0".to_string(),
protocol: Some(2),
})
);
assert_eq!(parse_version_answer(""), None, "no output is no answer");
assert_eq!(
parse_version_answer("shep-otel 0.1.3\nshep-protocol: two\n"),
Some(DogVersion {
version: "0.1.3".to_string(),
protocol: None,
}),
"a protocol that is not a decimal is unknown, not a refusal"
);
}
fn two_flag_dog(dir: &Path, schema_body: &str) -> PathBuf {
probe_script(
dir,
"shep-otel",
&format!("echo 'shep-otel 0.1.3'\necho 'shep-protocol: {PROTOCOL_VERSION}'"),
schema_body,
)
}
#[test]
fn a_dog_that_answers_a_schema_has_it_recorded() {
let dir = tempfile::tempdir().unwrap();
let bin = two_flag_dog(
dir.path(),
"echo '{\"title\":\"otel\",\"properties\":{\"endpoint\":{\"type\":\"string\"}}}'",
);
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
let DogSchema::Published(schema) = &vetted.schema else {
panic!("a dog that printed valid JSON Schema has one: {vetted:?}");
};
assert_eq!(
schema["properties"]["endpoint"]["type"], "string",
"the schema is kept as the dog wrote it, not summarised"
);
}
const PROBE_FORK_LIFETIME_SECS: u64 = 30;
const FORK_EXIT_DEADLINE: Duration = Duration::from_secs(5);
const FORK_EXIT_POLL_INTERVAL: Duration = Duration::from_millis(5);
fn waited_out(pid: nix::unistd::Pid) -> bool {
let started = Instant::now();
while started.elapsed() < FORK_EXIT_DEADLINE {
if nix::sys::signal::kill(pid, None).is_err() {
return true;
}
std::thread::sleep(FORK_EXIT_POLL_INTERVAL);
}
false
}
#[test]
fn a_fork_the_probe_left_behind_is_killed_with_it() {
let dir = tempfile::tempdir().unwrap();
let pid_file = dir.path().join("fork.pid");
let bin = two_flag_dog(
dir.path(),
&format!(
"sh -c 'echo $$ > {pid}.tmp; mv {pid}.tmp {pid}; exec sleep {secs}' \
>/dev/null 2>&1 &\n\
while [ ! -f {pid} ]; do sleep 0.01; done\n\
echo '{{}}'",
pid = pid_file.display(),
secs = PROBE_FORK_LIFETIME_SECS,
),
);
let schema = ask_schema(&bin, dir.path(), "otel", TEST_BUDGET);
assert!(
matches!(schema, DogSchema::Published(_)),
"the probe must answer, or the fork never wrote its pid: {schema:?}"
);
let pid = nix::unistd::Pid::from_raw(
std::fs::read_to_string(&pid_file)
.unwrap()
.trim()
.parse()
.unwrap(),
);
let gone = waited_out(pid);
if !gone {
let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGKILL);
}
assert!(gone, "a fork the probe left behind outlived it");
}
#[tokio::test]
async fn a_dog_whose_schema_run_exits_non_zero_has_no_schema_and_no_warning() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let bin = two_flag_dog(dir.path(), "echo '{}'; exit 3");
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
assert_eq!(
vetted.schema,
DogSchema::Silent,
"a failed run printed no answer, whatever reached stdout"
);
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success, "no schema is not a refusal");
let text = String::from_utf8(err).unwrap();
assert!(
!text.contains(DOG_SCHEMA_UNREADABLE_NOTICE),
"only unreadable output earns the warning, not a failed run: {text}"
);
}
#[tokio::test]
async fn a_dog_that_prints_no_schema_has_no_schema_and_no_warning() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let bin = two_flag_dog(dir.path(), "exit 0");
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
assert_eq!(vetted.schema, DogSchema::Silent, "silence is no schema");
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success, "silence is not a refusal");
let text = String::from_utf8(err).unwrap();
assert!(
!text.contains(DOG_SCHEMA_UNREADABLE_NOTICE),
"a dog that answered nothing is the ordinary case, not a warning: {text}"
);
}
#[tokio::test]
async fn a_dog_that_prints_invalid_json_is_adopted_with_one_warning() {
let dir = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, dir.path());
let bin = two_flag_dog(dir.path(), "echo 'error: unrecognized option --schema'");
let vetted = vet_binary_within(&bin, dir.path(), "otel", TEST_BUDGET).unwrap();
assert_eq!(
vetted.schema,
DogSchema::Unreadable,
"output that is not JSON is unreadable, not absent"
);
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success, "a broken schema is not a refusal");
let text = String::from_utf8(err).unwrap();
assert_eq!(
text.matches(&format!("notice[{DOG_SCHEMA_UNREADABLE_NOTICE}]"))
.count(),
1,
"one warning, and only one: {text}"
);
assert!(
paths.daemon_config.exists(),
"the dog is adopted despite the warning"
);
}
fn every_file_under(dir: &Path) -> Vec<(PathBuf, String)> {
let mut found = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return found;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
found.extend(every_file_under(&path));
} else if let Ok(text) = std::fs::read_to_string(&path) {
found.push((path, text));
}
}
found
}
#[tokio::test]
async fn a_published_schema_reaches_no_file_shep_writes() {
let home = tempfile::tempdir().unwrap();
let binaries = tempfile::tempdir().unwrap();
let paths = ShepPaths::resolve(&|_| None, home.path());
let marker = "only-ever-in-the-schema";
let bin = two_flag_dog(
binaries.path(),
&format!("echo '{{\"title\":\"{marker}\",\"properties\":{{}}}}'"),
);
let mut out = Vec::new();
let mut err = Vec::new();
let args = AdoptArgs {
name: Some("otel".to_string()),
path: bin,
};
let code = adopt(&mut streams(&mut out, &mut err), &paths, &args).await;
assert_eq!(code, ExitCode::Success);
let written = every_file_under(home.path());
assert!(
written.iter().any(|(_, text)| text.contains("otel")),
"the adopt has to have written the dog somewhere for this to mean anything: {written:?}"
);
for (path, text) in &written {
assert!(
!text.contains(marker),
"the schema was stored in {}: {text}",
path.display()
);
}
}
#[test]
fn debug_reports_that_there_is_a_schema_and_never_what_is_in_it() {
let schema = DogSchema::Published(serde_json::json!({"token": "hunter2"}));
assert_eq!(format!("{schema:?}"), "Published(..)");
assert_eq!(format!("{:?}", DogSchema::Silent), "Silent");
assert_eq!(format!("{:?}", DogSchema::Unreadable), "Unreadable");
}
#[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_within(&bin, dir.path(), "otel", TEST_BUDGET).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();
std::fs::write(
&paths.dogs_config,
"[otel]\nendpoint = \"127.0.0.1:4317\"\n\n[metrics]\nbind = \"127.0.0.1:9615\"\n",
)
.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}"
);
let dogs = std::fs::read_to_string(&paths.dogs_config).unwrap();
let dogs = shep_core::config::DogsConfig::load(Some(&dogs)).unwrap();
assert!(
!dogs.dog.contains_key("otel"),
"rehome must strike the section from dogs.toml, where a dog's config lives now"
);
assert!(
dogs.dog.contains_key("metrics"),
"and must leave every other dog's section exactly where it was"
);
}
#[tokio::test]
async fn rehoming_narrows_a_world_readable_dogs_toml() {
use std::os::unix::fs::PermissionsExt as _;
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();
std::fs::write(
&paths.dogs_config,
"[otel]\nendpoint = \"127.0.0.1:4317\"\n",
)
.unwrap();
std::fs::set_permissions(&paths.dogs_config, std::fs::Permissions::from_mode(0o644))
.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 mode = std::fs::metadata(&paths.dogs_config)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "mode was {mode:o}");
}
#[tokio::test]
async fn rehoming_with_no_dogs_toml_at_all_writes_none() {
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);
assert!(
!paths.dogs_config.exists(),
"nothing to strike, nothing 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"));
}
}