use std::ffi::OsString;
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use cli::{Format, GlobalArgs};
use commands::shep_toml::ShepToml;
use exit::ExitCode;
use output::Streams;
use shep_core::paths::{HOME_DIR_VAR, ShepPaths, user_home};
use crate::client::emit_error_locked;
use crate::{cli, commands, exit, output, status, welcome};
#[cfg_attr(windows, allow(dead_code))]
pub(crate) async fn print_shepherd_status(argv: &[OsString]) {
if !std::io::stderr().is_terminal() || argv.iter().any(|a| a == "--home") {
return;
}
let global = GlobalArgs {
home: None,
format: Format::Table,
quiet: false,
style: None,
};
let Ok(paths) = resolve_paths(&global) else {
return;
};
let status = status::ShepherdStatus::probe(&paths).await;
let mut err = std::io::stderr();
let _ = writeln!(err, "{}", status::one_line(&status));
}
pub(crate) fn resolve_paths(global: &GlobalArgs) -> Result<ShepPaths, HomeRefusal> {
resolve_paths_in(global, &|key| std::env::var_os(key))
}
fn resolve_paths_in(
global: &GlobalArgs,
var: &dyn Fn(&str) -> Option<OsString>,
) -> Result<ShepPaths, HomeRefusal> {
if let Some(named) = global.home.as_ref() {
require_absolute(HOME_KNOB, named)?;
require_utf8(HOME_KNOB, named)?;
}
let env = |key: &str| match key {
"SHEP_HOME" => global
.home
.as_ref()
.map(|p| p.to_string_lossy().into_owned()),
other => var(other).map(|value| value.to_string_lossy().into_owned()),
};
let home_dir = match (user_home(var), env("SHEP_HOME")) {
(Some(dir), _) => dir,
(None, Some(_)) => PathBuf::new(),
(None, None) => return Err(HomeRefusal::Unresolved),
};
if global.home.is_none() {
require_absolute(HOME_DIR_VAR, &home_dir)?;
require_utf8(HOME_DIR_VAR, &home_dir)?;
}
Ok(ShepPaths::resolve(&env, &home_dir))
}
fn require_utf8(knob: &'static str, candidate: &Path) -> Result<(), HomeRefusal> {
if candidate.to_str().is_some() {
return Ok(());
}
Err(HomeRefusal::NotUtf8 {
knob,
given: candidate.to_path_buf(),
})
}
fn require_absolute(knob: &'static str, candidate: &Path) -> Result<(), HomeRefusal> {
if candidate.is_absolute() {
return Ok(());
}
Err(HomeRefusal::Relative {
knob,
absolute: absolute_form(candidate),
given: candidate.to_path_buf(),
})
}
pub(crate) fn report_home_refusal(fmt: Format, refusal: &HomeRefusal) -> ExitCode {
let code = refusal.code();
emit_error_locked(fmt, code, &refusal.to_string());
code
}
pub(crate) fn absolute_form(candidate: &Path) -> Option<PathBuf> {
std::env::current_dir().ok().map(|cwd| cwd.join(candidate))
}
#[derive(Debug)]
pub(crate) enum HomeRefusal {
Unresolved,
Relative {
knob: &'static str,
given: PathBuf,
absolute: Option<PathBuf>,
},
NotUtf8 {
knob: &'static str,
given: PathBuf,
},
Missing(PathBuf),
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl core::fmt::Display for HomeRefusal {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Unresolved => f.write_str(UNRESOLVED_HOME),
Self::Relative {
knob,
given,
absolute,
} => write_relative_refusal(f, knob, given, absolute.as_deref()),
Self::NotUtf8 { knob, given } => write!(
f,
"{knob} must be valid UTF-8, and {given} is not\n \
shep carries the home path into the `{{{{SHEP_HOME}}}}` template, the log \
paths it reports, and the control socket's own name, all of which are text, \
so a byte that is not UTF-8 would be replaced and shep would use a \
directory you did not name",
given = given.display(),
),
Self::Missing(path) => write!(
f,
"no flock at {path}\n\
did you mean to drop {HOME_KNOB}? the default is {DEFAULT_HOME_SPELLING}\n\
to set up a flock there deliberately: {MKDIR_COMMAND} {quoted}",
path = one_line(path),
quoted = shell_quoted(path),
),
Self::Io { path, source } => write!(
f,
"could not create {path}: {source}",
path = one_line(path),
source = crate::terminal_safe::sanitise(&source.to_string()).0,
),
}
}
}
#[cfg(not(windows))]
fn shell_quoted(path: &Path) -> String {
format!("'{}'", one_line(path).replace('\'', r"'\''"))
}
#[cfg(windows)]
fn shell_quoted(path: &Path) -> String {
format!("\"{}\"", one_line(path))
}
fn one_line(path: &Path) -> String {
crate::terminal_safe::sanitise(&path.display().to_string()).0
}
impl core::error::Error for HomeRefusal {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Unresolved | Self::Relative { .. } | Self::NotUtf8 { .. } | Self::Missing(_) => {
None
}
Self::Io { source, .. } => Some(source),
}
}
}
impl HomeRefusal {
pub(crate) fn code(&self) -> ExitCode {
match self {
Self::Unresolved | Self::Relative { .. } | Self::NotUtf8 { .. } | Self::Missing(_) => {
ExitCode::Usage
}
Self::Io { .. } => ExitCode::Internal,
}
}
}
pub(crate) fn ensure_home(global: &GlobalArgs) -> Result<(ShepPaths, bool), HomeRefusal> {
let paths = resolve_paths(global)?;
ensure_home_at(paths, global.home.is_some())
}
fn ensure_home_at(paths: ShepPaths, explicit: bool) -> Result<(ShepPaths, bool), HomeRefusal> {
if paths.home.is_dir() {
return Ok((paths, false));
}
if explicit {
return Err(HomeRefusal::Missing(paths.home));
}
let built = {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(shep_daemon::boot::DIR_MODE)
.create(&paths.home)
}
#[cfg(not(unix))]
{
std::fs::DirBuilder::new()
.recursive(true)
.create(&paths.home)
}
};
match built {
Ok(()) => Ok((paths, true)),
Err(source) => Err(HomeRefusal::Io {
path: paths.home,
source,
}),
}
}
pub(crate) fn scaffold_first_run_interpreters(paths: &ShepPaths) {
if let Err(err) = ShepToml::edit(&paths.daemon_config, ShepToml::write_starter_interpreters) {
let mut err_stream = std::io::stderr();
let _ = writeln!(
err_stream,
"could not write a starter interpreter mapping to {}: {err}",
paths.daemon_config.display()
);
}
}
#[cfg(unix)]
pub(crate) fn create_default_home(
streams: &mut Streams<'_>,
paths: ShepPaths,
) -> Result<(), HomeRefusal> {
let (paths, home_is_new) = ensure_home_at(paths, false)?;
if home_is_new {
scaffold_first_run_interpreters(&paths);
welcome::on_first_run(streams, &paths.home, std::io::stderr().is_terminal());
}
Ok(())
}
#[cfg(not(windows))]
const UNRESOLVED_HOME: &str = "none of --home, $SHEP_HOME, or $HOME resolves a root directory";
#[cfg(windows)]
const UNRESOLVED_HOME: &str =
"none of --home, %SHEP_HOME%, or %USERPROFILE% resolves a root directory";
#[cfg(not(windows))]
pub(crate) const HOME_KNOB: &str = "--home/$SHEP_HOME";
#[cfg(windows)]
pub(crate) const HOME_KNOB: &str = "--home/%SHEP_HOME%";
#[cfg(not(windows))]
const DEFAULT_HOME_SPELLING: &str = "~/.shep";
#[cfg(windows)]
const DEFAULT_HOME_SPELLING: &str = r"%USERPROFILE%\.shep";
#[cfg(not(windows))]
const MKDIR_COMMAND: &str = "mkdir -p";
#[cfg(windows)]
const MKDIR_COMMAND: &str = "mkdir";
pub(crate) fn write_relative_refusal(
f: &mut core::fmt::Formatter<'_>,
knob: &str,
given: &Path,
absolute: Option<&Path>,
) -> core::fmt::Result {
write!(
f,
"{knob} must be an absolute path, not {given}\n\
a relative home is read against whatever directory shep runs in, so the flock it \
names is reachable from that one directory and nowhere else",
given = one_line(given),
)?;
match absolute {
Some(absolute) => write!(f, "\ndid you mean: {}", one_line(absolute)),
None => Ok(()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style;
#[cfg(not(windows))]
#[test]
fn the_mkdir_hint_survives_a_path_a_shell_would_split() {
let refusal = HomeRefusal::Missing(PathBuf::from("/tmp/my shep home"));
let text = refusal.to_string();
assert!(
text.contains("mkdir -p '/tmp/my shep home'"),
"the remedy must name one word: {text}"
);
assert!(text.contains("no flock at /tmp/my shep home"), "{text}");
}
#[test]
fn the_missing_home_refusal_reads_exactly_this() {
let text = HomeRefusal::Missing(PathBuf::from("/srv/api")).to_string();
let expected = if cfg!(windows) {
"no flock at /srv/api\n\
did you mean to drop --home/%SHEP_HOME%? the default is %USERPROFILE%\\.shep\n\
to set up a flock there deliberately: mkdir \"/srv/api\""
} else {
"no flock at /srv/api\n\
did you mean to drop --home/$SHEP_HOME? the default is ~/.shep\n\
to set up a flock there deliberately: mkdir -p '/srv/api'"
};
assert_eq!(text, expected);
}
#[test]
fn a_newline_in_a_missing_home_cannot_forge_a_line() {
let hostile = PathBuf::from("/tmp/forge-a\nnotice[ok]: your flock is fine");
let text = HomeRefusal::Missing(hostile).to_string();
assert_eq!(
text.lines().count(),
3,
"the refusal has three lines of its own: {text:?}"
);
assert!(
!text.lines().any(|line| line.starts_with("notice[")),
"a line was forged: {text:?}"
);
assert!(
text.starts_with("no flock at /tmp/forge-a notice[ok]: your flock is fine\n"),
"the newline must become a space, not vanish: {text:?}"
);
}
#[test]
fn a_newline_in_a_relative_home_cannot_forge_a_line() {
let refusal = HomeRefusal::Relative {
knob: "--home/$SHEP_HOME",
given: PathBuf::from("forge-b\nerror[internal]: shepherd compromised"),
absolute: Some(PathBuf::from(
"/w/forge-b\nerror[internal]: shepherd compromised",
)),
};
let text = refusal.to_string();
assert_eq!(text.lines().count(), 3, "{text:?}");
assert!(
!text.lines().any(|line| line.starts_with("error[")),
"a line was forged: {text:?}"
);
}
#[test]
fn neither_half_of_an_io_refusal_can_forge_a_line() {
let refusal = HomeRefusal::Io {
path: PathBuf::from("/tmp/forge-c\nnotice[ok]: created"),
source: std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied\nnotice[ok]: retried and it worked",
),
};
let text = refusal.to_string();
assert_eq!(text.lines().count(), 1, "{text:?}");
assert!(!text.contains('\n'), "{text:?}");
}
#[cfg(not(windows))]
#[test]
fn an_apostrophe_in_a_path_cannot_escape_the_mkdir_hint() {
let refusal = HomeRefusal::Missing(PathBuf::from("/tmp/rin's flock"));
let text = refusal.to_string();
assert!(
text.contains(r"mkdir -p '/tmp/rin'\''s flock'"),
"an embedded quote must close and reopen: {text}"
);
}
#[cfg(windows)]
#[test]
fn the_missing_home_remedy_is_one_a_windows_shell_can_run() {
let spaced = HomeRefusal::Missing(PathBuf::from(r"C:\tmp\my shep home")).to_string();
assert_eq!(
spaced,
"no flock at C:\\tmp\\my shep home\n\
did you mean to drop --home/%SHEP_HOME%? the default is %USERPROFILE%\\.shep\n\
to set up a flock there deliberately: mkdir \"C:\\tmp\\my shep home\""
);
let quoted = HomeRefusal::Missing(PathBuf::from(r"C:\tmp\rin's flock")).to_string();
assert_eq!(
quoted,
"no flock at C:\\tmp\\rin's flock\n\
did you mean to drop --home/%SHEP_HOME%? the default is %USERPROFILE%\\.shep\n\
to set up a flock there deliberately: mkdir \"C:\\tmp\\rin's flock\""
);
}
#[cfg(unix)]
fn paths_at(root: &std::path::Path) -> ShepPaths {
let home = root.join(".shep").to_string_lossy().into_owned();
let env = |key: &str| (key == "SHEP_HOME").then(|| home.clone());
ShepPaths::resolve(&env, std::path::Path::new("/nonexistent"))
}
#[cfg(unix)]
#[test]
fn a_missing_default_home_is_created_and_reported_as_new() {
let root = tempfile::tempdir().unwrap();
let (paths, created) =
ensure_home_at(paths_at(root.path()), false).expect("a default home is created");
assert_eq!(paths.home, root.path().join(".shep"));
assert!(
created,
"the first call must report that it created the home"
);
assert!(
paths.home.is_dir(),
"the home must exist on disk afterwards"
);
let (_, created_again) =
ensure_home_at(paths_at(root.path()), false).expect("second call succeeds");
assert!(
!created_again,
"a home that was already there is not newly created"
);
}
#[cfg(unix)]
#[test]
fn creating_startups_default_home_scaffolds_it_like_the_shared_gate() {
let root = tempfile::tempdir().unwrap();
let paths = paths_at(root.path());
let mut out = Vec::new();
let mut err = Vec::new();
let mut streams = Streams {
out: &mut out,
err: &mut err,
style: style::Presentation::BARE,
fmt: cli::Format::Table,
};
create_default_home(&mut streams, paths.clone()).expect("a default home is created");
assert!(paths.home.is_dir());
let written = std::fs::read_to_string(&paths.daemon_config).unwrap();
assert!(written.contains("[interpreters]"), "{written}");
create_default_home(&mut streams, paths).expect("a home already there is left alone");
}
#[cfg(unix)]
#[test]
fn an_explicitly_named_missing_home_is_refused_and_left_alone() {
let root = tempfile::tempdir().unwrap();
let paths = paths_at(&root.path().join("srv").join("typo"));
let named = paths.home.clone();
let refusal = ensure_home_at(paths, true).expect_err("a named missing home is refused");
assert_eq!(refusal.code(), ExitCode::Usage);
let message = refusal.to_string();
assert!(
message.contains(&named.display().to_string()),
"the refusal must name the path it refused: {message}"
);
assert!(
message.contains("~/.shep"),
"the refusal must point at the default as the way out: {message}"
);
assert!(
!named.exists(),
"a refused path must be left on disk exactly as it was found"
);
}
#[cfg(unix)]
#[test]
fn a_created_home_is_owner_only_from_the_moment_it_exists() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let (paths, _) = ensure_home_at(paths_at(root.path()), false).unwrap();
let mode = std::fs::metadata(&paths.home).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "a fresh $SHEP_HOME must be owner-only");
}
#[cfg(not(windows))]
const EXPLICIT_HOME: &str = "/tmp/explicit";
#[cfg(windows)]
const EXPLICIT_HOME: &str = r"C:\tmp\explicit";
#[test]
fn explicit_home_field_resolves_to_the_expected_shep_paths() {
let paths = resolve_paths(&global_with_home(Some(EXPLICIT_HOME))).unwrap();
assert_eq!(paths.home, std::path::Path::new(EXPLICIT_HOME));
#[cfg(unix)]
assert_eq!(
paths.socket,
std::path::Path::new("/tmp/explicit/run/shep.sock")
);
#[cfg(windows)]
assert_eq!(paths.socket, std::path::Path::new(&paths.pipe_name()));
}
fn global_with_home(home: Option<&str>) -> cli::GlobalArgs {
cli::GlobalArgs {
home: home.map(Into::into),
format: cli::Format::Table,
quiet: false,
style: None,
}
}
#[cfg(unix)]
fn non_utf8_home() -> std::path::PathBuf {
use std::os::unix::ffi::OsStringExt as _;
std::path::PathBuf::from(std::ffi::OsString::from_vec(
b"/tmp/shep-\xff-home".to_vec(),
))
}
#[cfg(unix)]
#[test]
fn a_home_that_is_not_utf8_is_refused_before_any_path_is_derived() {
let home = non_utf8_home();
let global = cli::GlobalArgs {
home: Some(home.clone()),
format: cli::Format::Table,
quiet: false,
style: None,
};
let Err(refusal) = resolve_paths(&global) else {
panic!("a home that is not UTF-8 must not resolve a layout");
};
assert!(
matches!(&refusal, HomeRefusal::NotUtf8 { knob, given }
if *knob == HOME_KNOB && given == &home),
"its own refusal, not the relative or unresolved one: {refusal:?}"
);
assert_eq!(refusal.code(), ExitCode::Usage);
let rendered = refusal.to_string();
assert!(
rendered.contains(HOME_KNOB),
"names the spelling to fix: {rendered}"
);
assert!(
rendered.contains("UTF-8"),
"says what is wrong with it: {rendered}"
);
assert!(
!rendered.contains('\u{2014}') && !rendered.contains('\u{2013}'),
"no em or en dash in copy a user reads: {rendered}"
);
}
#[cfg(unix)]
#[test]
fn a_relative_home_is_refused_as_relative_even_when_it_is_also_not_utf8() {
use std::os::unix::ffi::OsStringExt as _;
let home =
std::path::PathBuf::from(std::ffi::OsString::from_vec(b"rel-\xff-home".to_vec()));
let global = cli::GlobalArgs {
home: Some(home),
format: cli::Format::Table,
quiet: false,
style: None,
};
let Err(refusal) = resolve_paths(&global) else {
panic!("a relative home must not resolve a layout");
};
assert!(
matches!(refusal, HomeRefusal::Relative { .. }),
"the rootless reason wins, since it is the one an operator hits first"
);
}
#[test]
fn a_relative_home_is_refused_before_any_path_is_derived() {
let Err(refusal) = resolve_paths(&global_with_home(Some("rel-home"))) else {
panic!("a relative --home must not resolve a layout");
};
assert!(
matches!(&refusal, HomeRefusal::Relative { knob, given, .. }
if *knob == HOME_KNOB && given == std::path::Path::new("rel-home")),
"a rootless home is its own refusal, not the unresolved one"
);
assert_eq!(
refusal.code(),
ExitCode::Usage,
"a path shep cannot act on is the operator's to fix"
);
let rendered = refusal.to_string();
assert!(
rendered.contains("rel-home"),
"the refusal must quote the path as typed: {rendered}"
);
assert!(
rendered.contains(HOME_KNOB),
"the refusal must name the spelling to fix: {rendered}"
);
let cwd = std::env::current_dir().expect("a current directory");
assert!(
rendered.contains(&cwd.join("rel-home").display().to_string()),
"the remedy must name the absolute form of what was typed: {rendered}"
);
}
#[cfg(windows)]
#[test]
fn a_drive_relative_home_is_refused_on_windows() {
assert!(
matches!(
resolve_paths(&global_with_home(Some(r"\shep"))),
Err(HomeRefusal::Relative { .. })
),
r"`\shep` resolves against whichever drive is current"
);
}
#[cfg(unix)]
#[test]
fn a_home_directory_that_is_not_utf8_is_refused_and_names_its_own_variable() {
use std::os::unix::ffi::OsStringExt as _;
let mangled = |key: &str| {
matches!(key, "HOME" | "USERPROFILE" | "HOMEDRIVE" | "HOMEPATH")
.then(|| OsString::from_vec(b"/home/\xff".to_vec()))
};
let Err(refusal) = resolve_paths_in(&global_with_home(None), &mangled) else {
panic!("a home directory that is not UTF-8 must not resolve a layout");
};
assert!(
matches!(&refusal, HomeRefusal::NotUtf8 { knob, .. } if *knob == HOME_DIR_VAR),
"names the variable that supplied it, not the --home knob: {refusal:?}"
);
assert_eq!(refusal.code(), ExitCode::Usage);
assert!(
refusal.to_string().contains("UTF-8"),
"says what is wrong with it"
);
}
#[test]
fn a_relative_home_directory_is_refused_and_names_its_own_variable() {
let rootless = |key: &str| {
matches!(key, "HOME" | "USERPROFILE" | "HOMEDRIVE" | "HOMEPATH")
.then(|| OsString::from("ada"))
};
let Err(refusal) = resolve_paths_in(&global_with_home(None), &rootless) else {
panic!("a rootless home directory must not resolve a layout");
};
assert!(
matches!(&refusal, HomeRefusal::Relative { knob, given, .. }
if *knob == HOME_DIR_VAR && given == std::path::Path::new("ada")),
"the refusal must carry the home directory as supplied, not the joined `.shep`"
);
let rendered = refusal.to_string();
assert!(
rendered.contains(HOME_DIR_VAR),
"an operator cannot fix `--home` when `--home` is not what said it: {rendered}"
);
assert!(
!rendered.contains(HOME_KNOB),
"naming a knob the operator did not touch sends them to the wrong fix: {rendered}"
);
let cwd = std::env::current_dir().expect("a current directory");
assert!(
rendered.contains(&cwd.join("ada").display().to_string()),
"the remedy must name the absolute form of what was supplied: {rendered}"
);
}
#[test]
fn an_explicit_home_resolves_with_no_home_directory_at_all() {
let nothing = |_: &str| None;
let paths = resolve_paths_in(&global_with_home(Some(EXPLICIT_HOME)), ¬hing)
.expect("--home names a root on its own");
assert_eq!(
paths.home,
std::path::Path::new(EXPLICIT_HOME),
"the empty placeholder must not reach the resolved home"
);
assert!(
!paths.snapshot.starts_with(".shep"),
"a path rooted at the empty placeholder would start with `.shep`: {}",
paths.snapshot.display()
);
}
#[test]
fn an_absolute_home_directory_still_resolves_the_default_home() {
let rooted = |key: &str| (key == "HOME").then(|| OsString::from(EXPLICIT_HOME));
let paths = resolve_paths_in(&global_with_home(None), &rooted)
.expect("an absolute home directory resolves the default home");
assert_eq!(
paths.home,
std::path::Path::new(EXPLICIT_HOME).join(".shep")
);
}
}