use runner_manager_domain::model::StartMode;
pub const LOGINWINDOW_PREFERENCES: &str = "/Library/Preferences/com.apple.loginwindow";
pub const AUTO_LOGIN_STEPS: &str = "System Settings > Users & Groups > \"Automatically log in \
as\", choose the account the service runs as, and enter its \
password";
pub const FILEVAULT_STEPS: &str = "System Settings > Privacy & Security > FileVault > \"Turn \
Off…\" (macOS turns automatic login off while FileVault is \
on)";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutoLogin {
Off,
As(String),
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnattendedLogin {
pub auto_login: AutoLogin,
pub filevault_on: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resume {
Resumes,
FileVaultOn,
NoAutomaticLogin,
AutomaticLoginAsAnother(String),
Unknown(String),
}
impl Resume {
#[must_use]
pub fn detail(&self, account: &str) -> Option<String> {
match self {
Self::Resumes => None,
Self::FileVaultOn => Some(
"FileVault is on, so after a restart the Mac waits at its unlock screen until \
somebody types a password, and the service does not start until then"
.to_owned(),
),
Self::NoAutomaticLogin => Some(format!(
"automatic login is off, so after a restart this login-mode service waits for \
{account} to sign in"
)),
Self::AutomaticLoginAsAnother(other) => Some(format!(
"automatic login signs in {other}, not {account}, so after a restart this \
login-mode service waits for {account} to sign in"
)),
Self::Unknown(why) => Some(format!(
"whether this Mac signs {account} in after a restart could not be read: {why}"
)),
}
}
#[must_use]
pub fn remedy(&self) -> Option<String> {
match self {
Self::Resumes | Self::Unknown(_) => None,
Self::FileVaultOn => Some(FILEVAULT_STEPS.to_owned()),
Self::NoAutomaticLogin => Some(format!(
"{AUTO_LOGIN_STEPS}; or install the service to start at boot (`sudo \
runner-manager service install --start-at boot`)"
)),
Self::AutomaticLoginAsAnother(_) => Some(AUTO_LOGIN_STEPS.to_owned()),
}
}
#[must_use]
pub fn explain(&self, account: &str) -> Option<String> {
let detail = self.detail(account)?;
Some(match (self, self.remedy()) {
(Self::Unknown(_), _) | (_, None) => format!("{}.", capitalized(&detail)),
(_, Some(remedy)) => format!(
"This host does not resume work after an unattended reboot: {detail}. To have it \
come back by itself: {remedy}."
),
})
}
}
fn capitalized(text: &str) -> String {
let mut chars = text.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().chain(chars).collect()
})
}
#[must_use]
pub fn resume(found: &UnattendedLogin, mode: StartMode, account: &str) -> Resume {
match found.filevault_on {
Some(true) => return Resume::FileVaultOn,
Some(false) => {}
None => return Resume::Unknown("`fdesetup status` gave no answer".into()),
}
if mode == StartMode::Boot {
return Resume::Resumes;
}
match &found.auto_login {
AutoLogin::Off => Resume::NoAutomaticLogin,
AutoLogin::As(user) if user == account => Resume::Resumes,
AutoLogin::As(user) => Resume::AutomaticLoginAsAnother(user.clone()),
AutoLogin::Unknown(why) => Resume::Unknown(why.clone()),
}
}
#[must_use]
pub fn parse_filevault_status(output: &str) -> Option<bool> {
let output = output.trim();
if output.starts_with("FileVault is On")
|| output.contains("Encryption in progress")
|| output.contains("Decryption in progress")
{
Some(true)
} else if output.starts_with("FileVault is Off") {
Some(false)
} else {
None
}
}
#[must_use]
pub fn parse_auto_login(success: bool, stdout: &str, stderr: &str) -> AutoLogin {
let user = stdout.trim();
if success && !user.is_empty() {
AutoLogin::As(user.to_owned())
} else if stderr.contains("does not exist") {
AutoLogin::Off
} else {
AutoLogin::Unknown(format!("`defaults read` failed: {}", stderr.trim()))
}
}
#[must_use]
pub fn probe() -> Option<UnattendedLogin> {
#[cfg(target_os = "macos")]
{
let auto_login = match std::process::Command::new("/usr/bin/defaults")
.args(["read", LOGINWINDOW_PREFERENCES, "autoLoginUser"])
.output()
{
Ok(output) => parse_auto_login(
output.status.success(),
&String::from_utf8_lossy(&output.stdout),
&String::from_utf8_lossy(&output.stderr),
),
Err(error) => AutoLogin::Unknown(format!("/usr/bin/defaults: {error}")),
};
let filevault_on = std::process::Command::new("/usr/bin/fdesetup")
.arg("status")
.output()
.ok()
.and_then(|output| parse_filevault_status(&String::from_utf8_lossy(&output.stdout)));
Some(UnattendedLogin {
auto_login,
filevault_on,
})
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
#[must_use]
pub fn current_account() -> Option<String> {
#[cfg(unix)]
{
let output = std::process::Command::new("/usr/bin/id")
.arg("-un")
.output()
.ok()?;
let name = String::from_utf8_lossy(&output.stdout).trim().to_owned();
(output.status.success() && !name.is_empty()).then_some(name)
}
#[cfg(not(unix))]
{
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn found(auto_login: AutoLogin, filevault_on: Option<bool>) -> UnattendedLogin {
UnattendedLogin {
auto_login,
filevault_on,
}
}
#[test]
fn automatic_login_as_the_service_account_brings_a_login_service_back() {
let ivan = found(AutoLogin::As("ivan".into()), Some(false));
assert_eq!(resume(&ivan, StartMode::Login, "ivan"), Resume::Resumes);
assert_eq!(Resume::Resumes.explain("ivan"), None);
}
#[test]
fn a_login_service_without_automatic_login_waits_for_a_sign_in() {
let off = found(AutoLogin::Off, Some(false));
let verdict = resume(&off, StartMode::Login, "ivan");
assert_eq!(verdict, Resume::NoAutomaticLogin);
let said = verdict.explain("ivan").unwrap();
assert!(said.contains(AUTO_LOGIN_STEPS), "{said}");
let other = found(AutoLogin::As("kiosk".into()), Some(false));
assert_eq!(
resume(&other, StartMode::Login, "ivan"),
Resume::AutomaticLoginAsAnother("kiosk".into())
);
}
#[test]
fn filevault_holds_back_both_start_modes_and_automatic_login_does_not_help() {
let locked = found(AutoLogin::As("ivan".into()), Some(true));
for mode in [StartMode::Login, StartMode::Boot] {
let verdict = resume(&locked, mode, "ivan");
assert_eq!(verdict, Resume::FileVaultOn, "{mode}");
assert!(verdict.explain("ivan").unwrap().contains(FILEVAULT_STEPS));
}
let off = found(AutoLogin::Off, Some(false));
assert_eq!(resume(&off, StartMode::Boot, "root"), Resume::Resumes);
}
#[test]
fn an_unanswered_probe_is_unknown_and_never_a_verdict() {
let unread = found(AutoLogin::As("ivan".into()), None);
assert!(matches!(
resume(&unread, StartMode::Login, "ivan"),
Resume::Unknown(_)
));
let unreadable = found(AutoLogin::Unknown("no".into()), Some(false));
assert!(matches!(
resume(&unreadable, StartMode::Login, "ivan"),
Resume::Unknown(_)
));
}
#[test]
fn the_probe_outputs_are_read_as_macos_writes_them() {
assert_eq!(parse_filevault_status("FileVault is Off.\n"), Some(false));
assert_eq!(parse_filevault_status("FileVault is On.\n"), Some(true));
assert_eq!(
parse_filevault_status("Encryption in progress: Percent completed = 12.0\n"),
Some(true)
);
assert_eq!(
parse_filevault_status("FileVault is Off.\nDecryption in progress: 40.0\n"),
Some(true),
"the unlock screen stays until decryption ends"
);
assert_eq!(parse_filevault_status(""), None);
assert_eq!(
parse_auto_login(true, "ivan\n", ""),
AutoLogin::As("ivan".into())
);
assert_eq!(
parse_auto_login(
false,
"",
"The domain/default pair of (/Library/Preferences/com.apple.loginwindow, \
autoLoginUser) does not exist\n"
),
AutoLogin::Off
);
assert!(matches!(
parse_auto_login(false, "", "Operation not permitted"),
AutoLogin::Unknown(_)
));
}
}