use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::Receiver;
use std::time::{Duration, Instant};
use shep_core::dogs::{DogVersion, SCHEMA_FLAG, VERSION_FLAG, parse_version_answer};
use shep_core::paths::ShepPaths;
use shep_core::protocol::{MIN_SUPPORTED, SelectorSpec};
use crate::commands::shep_toml::ShepToml;
use crate::exit::ExitCode;
use crate::output::Streams;
#[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,
})
}
pub(crate) fn dog_env(home: &Path, name: &str) -> Vec<(String, OsString)> {
#[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(), OsString::from(path))];
env.extend(
INHERITED
.iter()
.filter_map(|key| std::env::var_os(key).map(|v| ((*key).to_string(), v))),
);
env.push(("SHEP_HOME".to_string(), home.as_os_str().to_owned()));
env.push(("SHEP_DOG_NAME".to_string(), OsString::from(name)));
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(dog_env(home, 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
}
pub(super) 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";
pub(super) 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";
pub(super) 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";
pub(super) 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);
}
}
#[cfg(all(test, unix))]
mod tests;